From 12fb5ebb3059fc3b2d28ec471c2d03cc67275160 Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 22 Apr 2026 12:26:18 +0000 Subject: [PATCH 01/36] feat: add findings hygiene report and control catalog layering (#264) ## Summary - add the workspace-scoped findings hygiene report, overview signal, and supporting classification service for broken assignments and stale in-progress work - add Spec 225 artifacts and focused findings hygiene test coverage alongside the new Filament page and workspace overview wiring - align product roadmap and spec candidates around the layered canonical control catalog, CIS library, and readiness model - extend SpecKit constitution and templates with the XCUT-001 shared-pattern reuse guidance ## Notes - validation commands and implementation close-out notes are documented in `specs/225-assignment-hygiene/plan.md` and `specs/225-assignment-hygiene/quickstart.md` - this PR targets `dev` from `225-assignment-hygiene` Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/264 --- .github/agents/copilot-instructions.md | 7 +- .github/skills/browsertest/SKILL.md | 295 ++++++++ .specify/memory/constitution.md | 23 +- .specify/templates/checklist-template.md | 22 +- .specify/templates/plan-template.md | 12 + .specify/templates/spec-template.md | 20 + .specify/templates/tasks-template.md | 5 + .../Pages/Findings/FindingsHygieneReport.php | 659 ++++++++++++++++++ .../Providers/Filament/AdminPanelProvider.php | 2 + .../FindingAssignmentHygieneService.php | 306 ++++++++ .../Findings/FindingWorkflowService.php | 53 ++ .../EnsureFilamentTenantSelected.php | 8 +- .../Workspaces/WorkspaceOverviewBuilder.php | 65 ++ .../findings-hygiene-report.blade.php | 103 +++ .../pages/workspace-overview.blade.php | 47 ++ ...ngsAssignmentHygieneClassificationTest.php | 232 ++++++ ...ngsAssignmentHygieneOverviewSignalTest.php | 176 +++++ .../FindingsAssignmentHygieneReportTest.php | 399 +++++++++++ docs/product/roadmap.md | 32 +- docs/product/spec-candidates.md | 288 ++++++-- .../checklists/requirements.md | 35 + .../assignment-hygiene.logical.openapi.yaml | 276 ++++++++ specs/225-assignment-hygiene/data-model.md | 176 +++++ specs/225-assignment-hygiene/plan.md | 271 +++++++ specs/225-assignment-hygiene/quickstart.md | 82 +++ specs/225-assignment-hygiene/research.md | 68 ++ specs/225-assignment-hygiene/spec.md | 244 +++++++ specs/225-assignment-hygiene/tasks.md | 216 ++++++ 28 files changed, 4041 insertions(+), 81 deletions(-) create mode 100644 .github/skills/browsertest/SKILL.md create mode 100644 apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php create mode 100644 apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php create mode 100644 apps/platform/resources/views/filament/pages/findings/findings-hygiene-report.blade.php create mode 100644 apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php create mode 100644 apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php create mode 100644 apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php create mode 100644 specs/225-assignment-hygiene/checklists/requirements.md create mode 100644 specs/225-assignment-hygiene/contracts/assignment-hygiene.logical.openapi.yaml create mode 100644 specs/225-assignment-hygiene/data-model.md create mode 100644 specs/225-assignment-hygiene/plan.md create mode 100644 specs/225-assignment-hygiene/quickstart.md create mode 100644 specs/225-assignment-hygiene/research.md create mode 100644 specs/225-assignment-hygiene/spec.md create mode 100644 specs/225-assignment-hygiene/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index c95bf526..aca1ec37 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -234,9 +234,10 @@ ## Active Technologies - File-based route files, Astro content collections under `src/content`, public assets, and planning documents under `specs/223-astrodeck-website-rebuild`; no database (223-astrodeck-website-rebuild) - 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) -- Filesystem only (`specs/226-astrodeck-inventory-planning/*`) (226-astrodeck-inventory-planning) - PHP 8.4.15 (feat/005-bulk-operations) @@ -272,7 +273,9 @@ ## Code Style ## Recent Changes - 226-astrodeck-inventory-planning: Added Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`) -- 226-astrodeck-inventory-planning: Added Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`) +- 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` ### Pre-production compatibility check diff --git a/.github/skills/browsertest/SKILL.md b/.github/skills/browsertest/SKILL.md new file mode 100644 index 00000000..efcd7578 --- /dev/null +++ b/.github/skills/browsertest/SKILL.md @@ -0,0 +1,295 @@ +--- +name: browsertest +description: Führe einen vollständigen Smoke-Browser-Test im Integrated Browser für das aktuelle Feature aus, inklusive Happy Path, zentraler Regressionen, Kontext-Prüfung und belastbarer Ergebniszusammenfassung. +license: MIT +metadata: + author: GitHub Copilot +--- + +# Browser Smoke Test + +## What This Skill Does + +Use this skill to validate the current feature end-to-end in the integrated browser. + +This is a focused smoke test, not a full exploratory test session. The goal is to prove that the primary operator flow: + +- loads in the correct auth, workspace, and tenant context +- exposes the expected controls and decision points +- completes the main happy path without blocking issues +- lands in the expected end state or canonical drilldown +- does not show obvious regressions such as broken navigation, missing data, or conflicting actions + +The skill should produce a concrete pass or fail result with actionable evidence. + +## When To Apply + +Activate this skill when: + +- the user asks to smoke test the current feature in the browser +- a new Filament page, dashboard signal, report, wizard, or detail flow was just added +- a UI regression fix needs confirmation in a real browser context +- the primary question is whether the feature works from an operator perspective +- you need a quick integration-level check without writing a full browser test suite first + +## What Success Looks Like + +A successful smoke test confirms all of the following: + +- the target route opens successfully +- the visible context is correct +- the main flow is usable +- the expected result appears after interaction +- the route or drilldown destination is correct +- the surface does not obviously violate its intended interaction model + +If the test cannot be completed, the output must clearly state whether the blocker is: + +- authentication +- missing data or fixture state +- routing +- UI interaction failure +- server error +- an unclear expected behavior contract + +Do not guess. If the route or state is blocked, report the blocker explicitly. + +## Preconditions + +Before running the browser smoke test, make sure you know: + +- the canonical route or entry point for the feature +- the primary operator action or happy path +- the expected success state +- whether the feature depends on a specific tenant, workspace, or seeded record + +When available, use the feature spec, quickstart, tasks, or current browser page as the source of truth. + +## Standard Workflow + +### 1. Define the smoke-test scope + +Identify: + +- the route to open +- the primary action to perform +- the expected end state +- one or two critical regressions that must not break + +The smoke test should stay narrow. Prefer one complete happy path plus one critical boundary over broad exploratory clicking. + +### 2. Establish the browser state + +- Reuse the current browser page if it already matches the target feature. +- Otherwise open the canonical route. +- Confirm the current auth and scope context before interacting. + +For this repo, that usually means checking whether the page is on: + +- `/admin/...` for workspace-context surfaces +- `/admin/t/{tenant}/...` for tenant-context surfaces + +### 3. Inspect before acting + +- Use `read_page` before interacting so you understand the live controls, refs, headings, and route context. +- Prefer `read_page` over screenshots for actual interaction planning. +- Use screenshots only for visual evidence or when the user asks for them. + +### 4. Execute the primary happy path + +Run the smallest meaningful flow that proves the feature works. + +Typical steps include: + +- open the page +- verify heading or key summary text +- click the primary CTA or row +- fill the minimum required form fields +- confirm modal or dialog text when relevant +- submit or navigate +- verify the expected destination or changed state + +After each meaningful action, re-read the page so the next step is based on current DOM state. + +### 5. Validate the outcome + +Check the exact result that matters for the feature. + +Examples: + +- a new row appears +- a status changes +- a success message appears +- a report filter changes the result set +- a row click lands on the canonical detail page +- a dashboard signal links to the correct report page + +### 6. Check for obvious regressions + +Even in a smoke test, verify a few core non-negotiables: + +- the page is not blank or half-rendered +- the main action is present and usable +- the visible context is correct +- the drilldown destination is canonical +- no obviously duplicated primary actions exist +- no stuck modal, spinner, or blocked interaction remains onscreen + +### 7. Capture evidence and summarize clearly + +Your result should state: + +- route tested +- context used +- steps executed +- pass or fail +- exact blocker or discrepancy if failed + +Include a screenshot only when it adds value. + +## Tool Usage Guidance + +Use the browser tools in this order by default: + +1. `read_page` +2. `click_element` +3. `type_in_page` +4. `handle_dialog` when needed +5. `navigate_page` or `open_browser_page` only when route changes are required +6. `run_playwright_code` only if the normal browser tools are insufficient +7. `screenshot_page` for evidence, not for primary navigation logic + +## Repo-Specific Guidance For TenantPilot + +### Workspace surfaces + +For `/admin` pages and similar workspace-context surfaces: + +- verify the page is reachable without forcing tenant-route assumptions +- confirm any summary signal or CTA lands on the canonical destination +- verify calm-state versus attention-state behavior when the feature defines both + +### Tenant surfaces + +For `/admin/t/{tenant}/...` pages: + +- verify the tenant context is explicit and correct +- verify drilldowns stay in the intended tenant scope +- treat cross-tenant leakage or silent scope changes as failures + +### Filament list or report surfaces + +For Filament tables, reports, or registry-style pages: + +- verify the heading and table shell render +- verify fixed filters or summary controls exist when the spec requires them +- verify row click or the primary inspect affordance behaves as designed +- verify empty-state messaging is specific rather than generic when the feature defines custom behavior + +### Filament detail pages + +For detail or view surfaces: + +- verify the canonical record loads +- verify expected sections or summary content are present +- verify critical actions or drillbacks are usable + +## Result Format + +Use a compact result format like this: + +```text +Browser smoke result: PASS +Route: /admin/findings/hygiene +Context: workspace member with visible hygiene issues +Steps: opened report -> verified filters -> clicked finding row -> landed on canonical finding detail +Verified: report rendered, primary interaction worked, drilldown route was correct +``` + +If the test fails: + +```text +Browser smoke result: FAIL +Route: /admin/findings/hygiene +Context: authenticated workspace member +Failed step: clicking the summary CTA +Expected: navigate to /admin/findings/hygiene +Actual: remained on /admin with no route change +Blocker: CTA appears rendered but is not interactive +``` + +## Examples + +### Example 1: Smoke test a new report page + +Use this when the feature adds a new read-only report. + +Steps: + +- open the canonical report route +- verify the page heading and main controls +- confirm the table or defined empty state is visible +- click one row or primary inspect affordance +- verify navigation lands on the canonical detail route + +Pass criteria: + +- report loads +- intended controls exist +- primary inspect path works + +### Example 2: Smoke test a dashboard signal + +Use this when the feature adds a summary signal on `/admin`. + +Steps: + +- open `/admin` +- find the signal +- verify the visible count or summary text +- click the CTA +- confirm navigation lands on the canonical downstream surface + +Pass criteria: + +- signal is visible in the correct state +- CTA text is present +- CTA opens the correct route + +### Example 3: Smoke test a tenant detail follow-up + +Use this when a workspace-level surface should drill into a tenant-level detail page. + +Steps: + +- open the workspace-level surface +- trigger the drilldown +- verify the target route includes the correct tenant and record +- confirm the target page actually loads the expected detail content + +Pass criteria: + +- drilldown route is canonical +- tenant context is correct +- destination content matches the selected record + +## Common Pitfalls + +- Clicking before reading the page state and refs +- Treating a blocked auth session as a feature failure +- Confusing workspace-context routes with tenant-context routes +- Reporting visual impressions without validating the actual interaction result +- Forgetting to re-read the page after a modal opens or a route changes +- Claiming success without verifying the final destination or changed state + +## Non-Goals + +This skill does not replace: + +- full exploratory QA +- formal Pest browser coverage +- accessibility review +- visual regression approval +- backend correctness tests + +It is a fast, real-browser confidence pass for the current feature. \ No newline at end of file diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index a0cabb61..dd1f054b 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,17 +1,30 @@ + +# Auto-Commit Changes + +Automatically stage and commit all changes after a Spec Kit command completes. + +## Behavior + +This command is invoked as a hook after (or before) core commands. It: + +1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`) +2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section +3. Looks up the specific event key to see if auto-commit is enabled +4. Falls back to `auto_commit.default` if no event-specific key exists +5. Uses the per-command `message` if configured, otherwise a default message +6. If enabled and there are uncommitted changes, runs `git add .` + `git commit` + +## Execution + +Determine the event name from the hook that triggered this command, then run the script: + +- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh ` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 ` + +Replace `` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). + +## Configuration + +In `.specify/extensions/git/git-config.yml`: + +```yaml +auto_commit: + default: false # Global toggle — set true to enable for all commands + after_specify: + enabled: true # Override per-command + message: "[Spec Kit] Add specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" +``` + +## Graceful Degradation + +- If Git is not available or the current directory is not a repository: skips with a warning +- If no config file exists: skips (disabled by default) +- If no changes to commit: skips with a message \ No newline at end of file diff --git a/.github/agents/speckit.git.feature.agent.md b/.github/agents/speckit.git.feature.agent.md new file mode 100644 index 00000000..080b52c9 --- /dev/null +++ b/.github/agents/speckit.git.feature.agent.md @@ -0,0 +1,70 @@ +--- +description: Create a feature branch with sequential or timestamp numbering +--- + + + + +# Create Feature Branch + +Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow. + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Environment Variable Override + +If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set: +- The script uses the exact value as the branch name, bypassing all prefix/suffix generation +- `--short-name`, `--number`, and `--timestamp` flags are ignored +- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name + +## Prerequisites + +- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, warn the user and skip branch creation + +## Branch Numbering Mode + +Determine the branch numbering strategy by checking configuration in this order: + +1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value +2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility) +3. Default to `sequential` if neither exists + +## Execution + +Generate a concise short name (2-4 words) for the branch: +- Analyze the feature description and extract the most meaningful keywords +- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug") +- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.) + +Run the appropriate script based on your platform: + +- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "" ""` +- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "" ""` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "" ""` +- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "" ""` + +**IMPORTANT**: +- Do NOT pass `--number` — the script determines the correct next number automatically +- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably +- You must only ever run this script once per feature +- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM` + +## Graceful Degradation + +If Git is not installed or the current directory is not a Git repository: +- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation` +- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them + +## Output + +The script outputs JSON with: +- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) +- `FEATURE_NUM`: The numeric or timestamp prefix used \ No newline at end of file diff --git a/.github/agents/speckit.git.initialize.agent.md b/.github/agents/speckit.git.initialize.agent.md new file mode 100644 index 00000000..f7522c10 --- /dev/null +++ b/.github/agents/speckit.git.initialize.agent.md @@ -0,0 +1,52 @@ +--- +description: Initialize a Git repository with an initial commit +--- + + + + +# Initialize Git Repository + +Initialize a Git repository in the current project directory if one does not already exist. + +## Execution + +Run the appropriate script from the project root: + +- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1` + +If the extension scripts are not found, fall back to: +- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"` +- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"` + +The script handles all checks internally: +- Skips if Git is not available +- Skips if already inside a Git repository +- Runs `git init`, `git add .`, and `git commit` with an initial commit message + +## Customization + +Replace the script to add project-specific Git initialization steps: +- Custom `.gitignore` templates +- Default branch naming (`git config init.defaultBranch`) +- Git LFS setup +- Git hooks installation +- Commit signing configuration +- Git Flow initialization + +## Output + +On success: +- `✓ Git repository initialized` + +## Graceful Degradation + +If Git is not installed: +- Warn the user +- Skip repository initialization +- The project continues to function without Git (specs can still be created under `specs/`) + +If Git is installed but `git init`, `git add .`, or `git commit` fails: +- Surface the error to the user +- Stop this command rather than continuing with a partially initialized repository \ No newline at end of file diff --git a/.github/agents/speckit.git.remote.agent.md b/.github/agents/speckit.git.remote.agent.md new file mode 100644 index 00000000..b8f0fb8b --- /dev/null +++ b/.github/agents/speckit.git.remote.agent.md @@ -0,0 +1,48 @@ +--- +description: Detect Git remote URL for GitHub integration +--- + + + + +# Detect Git Remote URL + +Detect the Git remote URL for integration with GitHub services (e.g., issue creation). + +## Prerequisites + +- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, output a warning and return empty: + ``` + [specify] Warning: Git repository not detected; cannot determine remote URL + ``` + +## Execution + +Run the following command to get the remote URL: + +```bash +git config --get remote.origin.url +``` + +## Output + +Parse the remote URL and determine: + +1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`) +2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`) +3. **Is GitHub**: Whether the remote points to a GitHub repository + +Supported URL formats: +- HTTPS: `https://github.com//.git` +- SSH: `git@github.com:/.git` + +> [!CAUTION] +> ONLY report a GitHub repository if the remote URL actually points to github.com. +> Do NOT assume the remote is GitHub if the URL format doesn't match. + +## Graceful Degradation + +If Git is not installed, the directory is not a Git repository, or no remote is configured: +- Return an empty result +- Do NOT error — other workflows should continue without Git remote information \ No newline at end of file diff --git a/.github/agents/speckit.git.validate.agent.md b/.github/agents/speckit.git.validate.agent.md new file mode 100644 index 00000000..dfd751f2 --- /dev/null +++ b/.github/agents/speckit.git.validate.agent.md @@ -0,0 +1,52 @@ +--- +description: Validate current branch follows feature branch naming conventions +--- + + + + +# Validate Feature Branch + +Validate that the current Git branch follows the expected feature branch naming conventions. + +## Prerequisites + +- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, output a warning and skip validation: + ``` + [specify] Warning: Git repository not detected; skipped branch validation + ``` + +## Validation Rules + +Get the current branch name: + +```bash +git rev-parse --abbrev-ref HEAD +``` + +The branch name must match one of these patterns: + +1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`) +2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`) + +## Execution + +If on a feature branch (matches either pattern): +- Output: `✓ On feature branch: ` +- Check if the corresponding spec directory exists under `specs/`: + - For sequential branches, look for `specs/-*` where prefix matches the numeric portion + - For timestamp branches, look for `specs/-*` where prefix matches the `YYYYMMDD-HHMMSS` portion +- If spec directory exists: `✓ Spec directory found: ` +- If spec directory missing: `⚠ No spec directory found for prefix ` + +If NOT on a feature branch: +- Output: `✗ Not on a feature branch. Current branch: ` +- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name` + +## Graceful Degradation + +If Git is not installed or the directory is not a Git repository: +- Check the `SPECIFY_FEATURE` environment variable as a fallback +- If set, validate that value against the naming patterns +- If not set, skip validation with a warning \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1ede861e..eb8641e8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -673,3 +673,8 @@ ### Replaced Utilities | decoration-slice | box-decoration-slice | | decoration-clone | box-decoration-clone | + + +For additional context about technologies to be used, project structure, +shell commands, and other important information, read the current plan + diff --git a/.github/prompts/speckit.git.commit.prompt.md b/.github/prompts/speckit.git.commit.prompt.md new file mode 100644 index 00000000..d87e3dfb --- /dev/null +++ b/.github/prompts/speckit.git.commit.prompt.md @@ -0,0 +1,3 @@ +--- +agent: speckit.git.commit +--- diff --git a/.github/prompts/speckit.git.feature.prompt.md b/.github/prompts/speckit.git.feature.prompt.md new file mode 100644 index 00000000..91ae09b1 --- /dev/null +++ b/.github/prompts/speckit.git.feature.prompt.md @@ -0,0 +1,3 @@ +--- +agent: speckit.git.feature +--- diff --git a/.github/prompts/speckit.git.initialize.prompt.md b/.github/prompts/speckit.git.initialize.prompt.md new file mode 100644 index 00000000..02c279cd --- /dev/null +++ b/.github/prompts/speckit.git.initialize.prompt.md @@ -0,0 +1,3 @@ +--- +agent: speckit.git.initialize +--- diff --git a/.github/prompts/speckit.git.remote.prompt.md b/.github/prompts/speckit.git.remote.prompt.md new file mode 100644 index 00000000..a521d9de --- /dev/null +++ b/.github/prompts/speckit.git.remote.prompt.md @@ -0,0 +1,3 @@ +--- +agent: speckit.git.remote +--- diff --git a/.github/prompts/speckit.git.validate.prompt.md b/.github/prompts/speckit.git.validate.prompt.md new file mode 100644 index 00000000..18ac70fe --- /dev/null +++ b/.github/prompts/speckit.git.validate.prompt.md @@ -0,0 +1,3 @@ +--- +agent: speckit.git.validate +--- diff --git a/.specify/extensions.yml b/.specify/extensions.yml new file mode 100644 index 00000000..efbbda97 --- /dev/null +++ b/.specify/extensions.yml @@ -0,0 +1,148 @@ +installed: [] +settings: + auto_execute_hooks: true +hooks: + before_constitution: + - extension: git + command: speckit.git.initialize + enabled: true + optional: false + prompt: Execute speckit.git.initialize? + description: Initialize Git repository before constitution setup + condition: null + before_specify: + - extension: git + command: speckit.git.feature + enabled: true + optional: false + prompt: Execute speckit.git.feature? + description: Create feature branch before specification + condition: null + before_clarify: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before clarification? + description: Auto-commit before spec clarification + condition: null + before_plan: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before planning? + description: Auto-commit before implementation planning + condition: null + before_tasks: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before task generation? + description: Auto-commit before task generation + condition: null + before_implement: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before implementation? + description: Auto-commit before implementation + condition: null + before_checklist: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before checklist? + description: Auto-commit before checklist generation + condition: null + before_analyze: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before analysis? + description: Auto-commit before analysis + condition: null + before_taskstoissues: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit outstanding changes before issue sync? + description: Auto-commit before tasks-to-issues conversion + condition: null + after_constitution: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit constitution changes? + description: Auto-commit after constitution update + condition: null + after_specify: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit specification changes? + description: Auto-commit after specification + condition: null + after_clarify: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit clarification changes? + description: Auto-commit after spec clarification + condition: null + after_plan: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit plan changes? + description: Auto-commit after implementation planning + condition: null + after_tasks: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit task changes? + description: Auto-commit after task generation + condition: null + after_implement: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit implementation changes? + description: Auto-commit after implementation + condition: null + after_checklist: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit checklist changes? + description: Auto-commit after checklist generation + condition: null + after_analyze: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit analysis results? + description: Auto-commit after analysis + condition: null + after_taskstoissues: + - extension: git + command: speckit.git.commit + enabled: true + optional: true + prompt: Commit after syncing issues? + description: Auto-commit after tasks-to-issues conversion + condition: null diff --git a/.specify/extensions/.registry b/.specify/extensions/.registry new file mode 100644 index 00000000..5fc71e49 --- /dev/null +++ b/.specify/extensions/.registry @@ -0,0 +1,44 @@ +{ + "schema_version": "1.0", + "extensions": { + "git": { + "version": "1.0.0", + "source": "local", + "manifest_hash": "sha256:9731aa8143a72fbebfdb440f155038ab42642517c2b2bdbbf67c8fdbe076ed79", + "enabled": true, + "priority": 10, + "registered_commands": { + "agy": [ + "speckit.git.feature", + "speckit.git.validate", + "speckit.git.remote", + "speckit.git.initialize", + "speckit.git.commit" + ], + "codex": [ + "speckit.git.feature", + "speckit.git.validate", + "speckit.git.remote", + "speckit.git.initialize", + "speckit.git.commit" + ], + "copilot": [ + "speckit.git.feature", + "speckit.git.validate", + "speckit.git.remote", + "speckit.git.initialize", + "speckit.git.commit" + ], + "gemini": [ + "speckit.git.feature", + "speckit.git.validate", + "speckit.git.remote", + "speckit.git.initialize", + "speckit.git.commit" + ] + }, + "registered_skills": [], + "installed_at": "2026-04-22T21:58:03.029565+00:00" + } + } +} \ No newline at end of file diff --git a/.specify/extensions/git/README.md b/.specify/extensions/git/README.md new file mode 100644 index 00000000..31ba75c3 --- /dev/null +++ b/.specify/extensions/git/README.md @@ -0,0 +1,100 @@ +# Git Branching Workflow Extension + +Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit. + +## Overview + +This extension provides Git operations as an optional, self-contained module. It manages: + +- **Repository initialization** with configurable commit messages +- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering +- **Branch validation** to ensure branches follow naming conventions +- **Git remote detection** for GitHub integration (e.g., issue creation) +- **Auto-commit** after core commands (configurable per-command with custom messages) + +## Commands + +| Command | Description | +|---------|-------------| +| `speckit.git.initialize` | Initialize a Git repository with a configurable commit message | +| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering | +| `speckit.git.validate` | Validate current branch follows feature branch naming conventions | +| `speckit.git.remote` | Detect Git remote URL for GitHub integration | +| `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) | + +## Hooks + +| Event | Command | Optional | Description | +|-------|---------|----------|-------------| +| `before_constitution` | `speckit.git.initialize` | No | Init git repo before constitution | +| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification | +| `before_clarify` | `speckit.git.commit` | Yes | Commit outstanding changes before clarification | +| `before_plan` | `speckit.git.commit` | Yes | Commit outstanding changes before planning | +| `before_tasks` | `speckit.git.commit` | Yes | Commit outstanding changes before task generation | +| `before_implement` | `speckit.git.commit` | Yes | Commit outstanding changes before implementation | +| `before_checklist` | `speckit.git.commit` | Yes | Commit outstanding changes before checklist | +| `before_analyze` | `speckit.git.commit` | Yes | Commit outstanding changes before analysis | +| `before_taskstoissues` | `speckit.git.commit` | Yes | Commit outstanding changes before issue sync | +| `after_constitution` | `speckit.git.commit` | Yes | Auto-commit after constitution update | +| `after_specify` | `speckit.git.commit` | Yes | Auto-commit after specification | +| `after_clarify` | `speckit.git.commit` | Yes | Auto-commit after clarification | +| `after_plan` | `speckit.git.commit` | Yes | Auto-commit after planning | +| `after_tasks` | `speckit.git.commit` | Yes | Auto-commit after task generation | +| `after_implement` | `speckit.git.commit` | Yes | Auto-commit after implementation | +| `after_checklist` | `speckit.git.commit` | Yes | Auto-commit after checklist | +| `after_analyze` | `speckit.git.commit` | Yes | Auto-commit after analysis | +| `after_taskstoissues` | `speckit.git.commit` | Yes | Auto-commit after issue sync | + +## Configuration + +Configuration is stored in `.specify/extensions/git/git-config.yml`: + +```yaml +# Branch numbering strategy: "sequential" or "timestamp" +branch_numbering: sequential + +# Custom commit message for git init +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit per command (all disabled by default) +# Example: enable auto-commit after specify +auto_commit: + default: false + after_specify: + enabled: true + message: "[Spec Kit] Add specification" +``` + +## Installation + +```bash +# Install the bundled git extension (no network required) +specify extension add git +``` + +## Disabling + +```bash +# Disable the git extension (spec creation continues without branching) +specify extension disable git + +# Re-enable it +specify extension enable git +``` + +## Graceful Degradation + +When Git is not installed or the directory is not a Git repository: +- Spec directories are still created under `specs/` +- Branch creation is skipped with a warning +- Branch validation is skipped with a warning +- Remote detection returns empty results + +## Scripts + +The extension bundles cross-platform scripts: + +- `scripts/bash/create-new-feature.sh` — Bash implementation +- `scripts/bash/git-common.sh` — Shared Git utilities (Bash) +- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation +- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell) diff --git a/.specify/extensions/git/commands/speckit.git.commit.md b/.specify/extensions/git/commands/speckit.git.commit.md new file mode 100644 index 00000000..e606f911 --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.commit.md @@ -0,0 +1,48 @@ +--- +description: "Auto-commit changes after a Spec Kit command completes" +--- + +# Auto-Commit Changes + +Automatically stage and commit all changes after a Spec Kit command completes. + +## Behavior + +This command is invoked as a hook after (or before) core commands. It: + +1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`) +2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section +3. Looks up the specific event key to see if auto-commit is enabled +4. Falls back to `auto_commit.default` if no event-specific key exists +5. Uses the per-command `message` if configured, otherwise a default message +6. If enabled and there are uncommitted changes, runs `git add .` + `git commit` + +## Execution + +Determine the event name from the hook that triggered this command, then run the script: + +- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh ` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 ` + +Replace `` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). + +## Configuration + +In `.specify/extensions/git/git-config.yml`: + +```yaml +auto_commit: + default: false # Global toggle — set true to enable for all commands + after_specify: + enabled: true # Override per-command + message: "[Spec Kit] Add specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" +``` + +## Graceful Degradation + +- If Git is not available or the current directory is not a repository: skips with a warning +- If no config file exists: skips (disabled by default) +- If no changes to commit: skips with a message diff --git a/.specify/extensions/git/commands/speckit.git.feature.md b/.specify/extensions/git/commands/speckit.git.feature.md new file mode 100644 index 00000000..1a9c5e35 --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.feature.md @@ -0,0 +1,67 @@ +--- +description: "Create a feature branch with sequential or timestamp numbering" +--- + +# Create Feature Branch + +Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow. + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Environment Variable Override + +If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set: +- The script uses the exact value as the branch name, bypassing all prefix/suffix generation +- `--short-name`, `--number`, and `--timestamp` flags are ignored +- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name + +## Prerequisites + +- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, warn the user and skip branch creation + +## Branch Numbering Mode + +Determine the branch numbering strategy by checking configuration in this order: + +1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value +2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility) +3. Default to `sequential` if neither exists + +## Execution + +Generate a concise short name (2-4 words) for the branch: +- Analyze the feature description and extract the most meaningful keywords +- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug") +- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.) + +Run the appropriate script based on your platform: + +- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "" ""` +- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "" ""` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "" ""` +- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "" ""` + +**IMPORTANT**: +- Do NOT pass `--number` — the script determines the correct next number automatically +- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably +- You must only ever run this script once per feature +- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM` + +## Graceful Degradation + +If Git is not installed or the current directory is not a Git repository: +- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation` +- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them + +## Output + +The script outputs JSON with: +- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) +- `FEATURE_NUM`: The numeric or timestamp prefix used diff --git a/.specify/extensions/git/commands/speckit.git.initialize.md b/.specify/extensions/git/commands/speckit.git.initialize.md new file mode 100644 index 00000000..4451ee6b --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.initialize.md @@ -0,0 +1,49 @@ +--- +description: "Initialize a Git repository with an initial commit" +--- + +# Initialize Git Repository + +Initialize a Git repository in the current project directory if one does not already exist. + +## Execution + +Run the appropriate script from the project root: + +- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1` + +If the extension scripts are not found, fall back to: +- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"` +- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"` + +The script handles all checks internally: +- Skips if Git is not available +- Skips if already inside a Git repository +- Runs `git init`, `git add .`, and `git commit` with an initial commit message + +## Customization + +Replace the script to add project-specific Git initialization steps: +- Custom `.gitignore` templates +- Default branch naming (`git config init.defaultBranch`) +- Git LFS setup +- Git hooks installation +- Commit signing configuration +- Git Flow initialization + +## Output + +On success: +- `✓ Git repository initialized` + +## Graceful Degradation + +If Git is not installed: +- Warn the user +- Skip repository initialization +- The project continues to function without Git (specs can still be created under `specs/`) + +If Git is installed but `git init`, `git add .`, or `git commit` fails: +- Surface the error to the user +- Stop this command rather than continuing with a partially initialized repository diff --git a/.specify/extensions/git/commands/speckit.git.remote.md b/.specify/extensions/git/commands/speckit.git.remote.md new file mode 100644 index 00000000..712a3e8b --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.remote.md @@ -0,0 +1,45 @@ +--- +description: "Detect Git remote URL for GitHub integration" +--- + +# Detect Git Remote URL + +Detect the Git remote URL for integration with GitHub services (e.g., issue creation). + +## Prerequisites + +- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, output a warning and return empty: + ``` + [specify] Warning: Git repository not detected; cannot determine remote URL + ``` + +## Execution + +Run the following command to get the remote URL: + +```bash +git config --get remote.origin.url +``` + +## Output + +Parse the remote URL and determine: + +1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`) +2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`) +3. **Is GitHub**: Whether the remote points to a GitHub repository + +Supported URL formats: +- HTTPS: `https://github.com//.git` +- SSH: `git@github.com:/.git` + +> [!CAUTION] +> ONLY report a GitHub repository if the remote URL actually points to github.com. +> Do NOT assume the remote is GitHub if the URL format doesn't match. + +## Graceful Degradation + +If Git is not installed, the directory is not a Git repository, or no remote is configured: +- Return an empty result +- Do NOT error — other workflows should continue without Git remote information diff --git a/.specify/extensions/git/commands/speckit.git.validate.md b/.specify/extensions/git/commands/speckit.git.validate.md new file mode 100644 index 00000000..dd84618c --- /dev/null +++ b/.specify/extensions/git/commands/speckit.git.validate.md @@ -0,0 +1,49 @@ +--- +description: "Validate current branch follows feature branch naming conventions" +--- + +# Validate Feature Branch + +Validate that the current Git branch follows the expected feature branch naming conventions. + +## Prerequisites + +- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, output a warning and skip validation: + ``` + [specify] Warning: Git repository not detected; skipped branch validation + ``` + +## Validation Rules + +Get the current branch name: + +```bash +git rev-parse --abbrev-ref HEAD +``` + +The branch name must match one of these patterns: + +1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`) +2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`) + +## Execution + +If on a feature branch (matches either pattern): +- Output: `✓ On feature branch: ` +- Check if the corresponding spec directory exists under `specs/`: + - For sequential branches, look for `specs/-*` where prefix matches the numeric portion + - For timestamp branches, look for `specs/-*` where prefix matches the `YYYYMMDD-HHMMSS` portion +- If spec directory exists: `✓ Spec directory found: ` +- If spec directory missing: `⚠ No spec directory found for prefix ` + +If NOT on a feature branch: +- Output: `✗ Not on a feature branch. Current branch: ` +- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name` + +## Graceful Degradation + +If Git is not installed or the directory is not a Git repository: +- Check the `SPECIFY_FEATURE` environment variable as a fallback +- If set, validate that value against the naming patterns +- If not set, skip validation with a warning diff --git a/.specify/extensions/git/config-template.yml b/.specify/extensions/git/config-template.yml new file mode 100644 index 00000000..8c414bab --- /dev/null +++ b/.specify/extensions/git/config-template.yml @@ -0,0 +1,62 @@ +# Git Branching Workflow Extension Configuration +# Copied to .specify/extensions/git/git-config.yml on install + +# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) +branch_numbering: sequential + +# Commit message used by `git commit` during repository initialization +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit before/after core commands. +# Set "default" to enable for all commands, then override per-command. +# Each key can be true/false. Message is customizable per-command. +auto_commit: + default: false + before_clarify: + enabled: false + message: "[Spec Kit] Save progress before clarification" + before_plan: + enabled: false + message: "[Spec Kit] Save progress before planning" + before_tasks: + enabled: false + message: "[Spec Kit] Save progress before task generation" + before_implement: + enabled: false + message: "[Spec Kit] Save progress before implementation" + before_checklist: + enabled: false + message: "[Spec Kit] Save progress before checklist" + before_analyze: + enabled: false + message: "[Spec Kit] Save progress before analysis" + before_taskstoissues: + enabled: false + message: "[Spec Kit] Save progress before issue sync" + after_constitution: + enabled: false + message: "[Spec Kit] Add project constitution" + after_specify: + enabled: false + message: "[Spec Kit] Add specification" + after_clarify: + enabled: false + message: "[Spec Kit] Clarify specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" + after_tasks: + enabled: false + message: "[Spec Kit] Add tasks" + after_implement: + enabled: false + message: "[Spec Kit] Implementation progress" + after_checklist: + enabled: false + message: "[Spec Kit] Add checklist" + after_analyze: + enabled: false + message: "[Spec Kit] Add analysis report" + after_taskstoissues: + enabled: false + message: "[Spec Kit] Sync tasks to issues" diff --git a/.specify/extensions/git/extension.yml b/.specify/extensions/git/extension.yml new file mode 100644 index 00000000..13c1977e --- /dev/null +++ b/.specify/extensions/git/extension.yml @@ -0,0 +1,140 @@ +schema_version: "1.0" + +extension: + id: git + name: "Git Branching Workflow" + version: "1.0.0" + description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection" + author: spec-kit-core + repository: https://github.com/github/spec-kit + license: MIT + +requires: + speckit_version: ">=0.2.0" + tools: + - name: git + required: false + +provides: + commands: + - name: speckit.git.feature + file: commands/speckit.git.feature.md + description: "Create a feature branch with sequential or timestamp numbering" + - name: speckit.git.validate + file: commands/speckit.git.validate.md + description: "Validate current branch follows feature branch naming conventions" + - name: speckit.git.remote + file: commands/speckit.git.remote.md + description: "Detect Git remote URL for GitHub integration" + - name: speckit.git.initialize + file: commands/speckit.git.initialize.md + description: "Initialize a Git repository with an initial commit" + - name: speckit.git.commit + file: commands/speckit.git.commit.md + description: "Auto-commit changes after a Spec Kit command completes" + + config: + - name: "git-config.yml" + template: "config-template.yml" + description: "Git branching configuration" + required: false + +hooks: + before_constitution: + command: speckit.git.initialize + optional: false + description: "Initialize Git repository before constitution setup" + before_specify: + command: speckit.git.feature + optional: false + description: "Create feature branch before specification" + before_clarify: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before clarification?" + description: "Auto-commit before spec clarification" + before_plan: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before planning?" + description: "Auto-commit before implementation planning" + before_tasks: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before task generation?" + description: "Auto-commit before task generation" + before_implement: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before implementation?" + description: "Auto-commit before implementation" + before_checklist: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before checklist?" + description: "Auto-commit before checklist generation" + before_analyze: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before analysis?" + description: "Auto-commit before analysis" + before_taskstoissues: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before issue sync?" + description: "Auto-commit before tasks-to-issues conversion" + after_constitution: + command: speckit.git.commit + optional: true + prompt: "Commit constitution changes?" + description: "Auto-commit after constitution update" + after_specify: + command: speckit.git.commit + optional: true + prompt: "Commit specification changes?" + description: "Auto-commit after specification" + after_clarify: + command: speckit.git.commit + optional: true + prompt: "Commit clarification changes?" + description: "Auto-commit after spec clarification" + after_plan: + command: speckit.git.commit + optional: true + prompt: "Commit plan changes?" + description: "Auto-commit after implementation planning" + after_tasks: + command: speckit.git.commit + optional: true + prompt: "Commit task changes?" + description: "Auto-commit after task generation" + after_implement: + command: speckit.git.commit + optional: true + prompt: "Commit implementation changes?" + description: "Auto-commit after implementation" + after_checklist: + command: speckit.git.commit + optional: true + prompt: "Commit checklist changes?" + description: "Auto-commit after checklist generation" + after_analyze: + command: speckit.git.commit + optional: true + prompt: "Commit analysis results?" + description: "Auto-commit after analysis" + after_taskstoissues: + command: speckit.git.commit + optional: true + prompt: "Commit after syncing issues?" + description: "Auto-commit after tasks-to-issues conversion" + +tags: + - "git" + - "branching" + - "workflow" + +config: + defaults: + branch_numbering: sequential + init_commit_message: "[Spec Kit] Initial commit" diff --git a/.specify/extensions/git/git-config.yml b/.specify/extensions/git/git-config.yml new file mode 100644 index 00000000..8c414bab --- /dev/null +++ b/.specify/extensions/git/git-config.yml @@ -0,0 +1,62 @@ +# Git Branching Workflow Extension Configuration +# Copied to .specify/extensions/git/git-config.yml on install + +# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) +branch_numbering: sequential + +# Commit message used by `git commit` during repository initialization +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit before/after core commands. +# Set "default" to enable for all commands, then override per-command. +# Each key can be true/false. Message is customizable per-command. +auto_commit: + default: false + before_clarify: + enabled: false + message: "[Spec Kit] Save progress before clarification" + before_plan: + enabled: false + message: "[Spec Kit] Save progress before planning" + before_tasks: + enabled: false + message: "[Spec Kit] Save progress before task generation" + before_implement: + enabled: false + message: "[Spec Kit] Save progress before implementation" + before_checklist: + enabled: false + message: "[Spec Kit] Save progress before checklist" + before_analyze: + enabled: false + message: "[Spec Kit] Save progress before analysis" + before_taskstoissues: + enabled: false + message: "[Spec Kit] Save progress before issue sync" + after_constitution: + enabled: false + message: "[Spec Kit] Add project constitution" + after_specify: + enabled: false + message: "[Spec Kit] Add specification" + after_clarify: + enabled: false + message: "[Spec Kit] Clarify specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" + after_tasks: + enabled: false + message: "[Spec Kit] Add tasks" + after_implement: + enabled: false + message: "[Spec Kit] Implementation progress" + after_checklist: + enabled: false + message: "[Spec Kit] Add checklist" + after_analyze: + enabled: false + message: "[Spec Kit] Add analysis report" + after_taskstoissues: + enabled: false + message: "[Spec Kit] Sync tasks to issues" diff --git a/.specify/extensions/git/scripts/bash/auto-commit.sh b/.specify/extensions/git/scripts/bash/auto-commit.sh new file mode 100755 index 00000000..f0b42318 --- /dev/null +++ b/.specify/extensions/git/scripts/bash/auto-commit.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Git extension: auto-commit.sh +# Automatically commit changes after a Spec Kit command completes. +# Checks per-command config keys in git-config.yml before committing. +# +# Usage: auto-commit.sh +# e.g.: auto-commit.sh after_specify + +set -e + +EVENT_NAME="${1:-}" +if [ -z "$EVENT_NAME" ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)" +cd "$REPO_ROOT" + +# Check if git is available +if ! command -v git >/dev/null 2>&1; then + echo "[specify] Warning: Git not found; skipped auto-commit" >&2 + exit 0 +fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2 + exit 0 +fi + +# Read per-command config from git-config.yml +_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml" +_enabled=false +_commit_msg="" + +if [ -f "$_config_file" ]; then + # Parse the auto_commit section for this event. + # Look for auto_commit..enabled and .message + # Also check auto_commit.default as fallback. + _in_auto_commit=false + _in_event=false + _default_enabled=false + + while IFS= read -r _line; do + # Detect auto_commit: section + if echo "$_line" | grep -q '^auto_commit:'; then + _in_auto_commit=true + _in_event=false + continue + fi + + # Exit auto_commit section on next top-level key + if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then + break + fi + + if $_in_auto_commit; then + # Check default key + if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then + _val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + [ "$_val" = "true" ] && _default_enabled=true + fi + + # Detect our event subsection + if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then + _in_event=true + continue + fi + + # Inside our event subsection + if $_in_event; then + # Exit on next sibling key (same indent level as event name) + if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then + _in_event=false + continue + fi + if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then + _val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + [ "$_val" = "true" ] && _enabled=true + [ "$_val" = "false" ] && _enabled=false + fi + if echo "$_line" | grep -Eq '[[:space:]]+message:'; then + _commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//') + fi + fi + fi + done < "$_config_file" + + # If event-specific key not found, use default + if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then + # Only use default if the event wasn't explicitly set to false + # Check if event section existed at all + if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then + _enabled=true + fi + fi +else + # No config file — auto-commit disabled by default + exit 0 +fi + +if [ "$_enabled" != "true" ]; then + exit 0 +fi + +# Check if there are changes to commit +if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then + echo "[specify] No changes to commit after $EVENT_NAME" >&2 + exit 0 +fi + +# Derive a human-readable command name from the event +# e.g., after_specify -> specify, before_plan -> plan +_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//') +_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after') + +# Use custom message if configured, otherwise default +if [ -z "$_commit_msg" ]; then + _commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}" +fi + +# Stage and commit +_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; } +_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; } + +echo "[OK] Changes committed ${_phase} ${_command_name}" >&2 diff --git a/.specify/extensions/git/scripts/bash/create-new-feature.sh b/.specify/extensions/git/scripts/bash/create-new-feature.sh new file mode 100755 index 00000000..286aaf76 --- /dev/null +++ b/.specify/extensions/git/scripts/bash/create-new-feature.sh @@ -0,0 +1,453 @@ +#!/usr/bin/env bash +# Git extension: create-new-feature.sh +# Adapted from core scripts/bash/create-new-feature.sh for extension layout. +# Sources common.sh from the project's installed scripts, falling back to +# git-common.sh for minimal git helpers. + +set -e + +JSON_MODE=false +DRY_RUN=false +ALLOW_EXISTING=false +SHORT_NAME="" +BRANCH_NUMBER="" +USE_TIMESTAMP=false +ARGS=() +i=1 +while [ $i -le $# ]; do + arg="${!i}" + case "$arg" in + --json) + JSON_MODE=true + ;; + --dry-run) + DRY_RUN=true + ;; + --allow-existing-branch) + ALLOW_EXISTING=true + ;; + --short-name) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + SHORT_NAME="$next_arg" + ;; + --number) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + BRANCH_NUMBER="$next_arg" + if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then + echo 'Error: --number must be a non-negative integer' >&2 + exit 1 + fi + ;; + --timestamp) + USE_TIMESTAMP=true + ;; + --help|-h) + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "" + echo "Options:" + echo " --json Output in JSON format" + echo " --dry-run Compute branch name without creating the branch" + echo " --allow-existing-branch Switch to branch if it already exists instead of failing" + echo " --short-name Provide a custom short name (2-4 words) for the branch" + echo " --number N Specify branch number manually (overrides auto-detection)" + echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + echo " --help, -h Show this help message" + echo "" + echo "Environment variables:" + echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" + echo "" + echo "Examples:" + echo " $0 'Add user authentication system' --short-name 'user-auth'" + echo " $0 'Implement OAuth2 integration for API' --number 5" + echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" + echo " GIT_BRANCH_NAME=my-branch $0 'feature description'" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac + i=$((i + 1)) +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + exit 1 +fi + +# Trim whitespace and validate description is not empty +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs) +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 + exit 1 +fi + +# Function to get highest number from specs directory +get_highest_from_specs() { + local specs_dir="$1" + local highest=0 + + if [ -d "$specs_dir" ]; then + for dir in "$specs_dir"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. + if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$dirname" | grep -Eo '^[0-9]+') + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + fi + + echo "$highest" +} + +# Function to get highest number from git branches +get_highest_from_branches() { + git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number +} + +# Extract the highest sequential feature number from a list of ref names (one per line). +_extract_highest_number() { + local highest=0 + while IFS= read -r name; do + [ -z "$name" ] && continue + if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + echo "$highest" +} + +# Function to get highest number from remote branches without fetching (side-effect-free) +get_highest_from_remote_refs() { + local highest=0 + + for remote in $(git remote 2>/dev/null); do + local remote_highest + remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number) + if [ "$remote_highest" -gt "$highest" ]; then + highest=$remote_highest + fi + done + + echo "$highest" +} + +# Function to check existing branches and return next available number. +check_existing_branches() { + local specs_dir="$1" + local skip_fetch="${2:-false}" + + if [ "$skip_fetch" = true ]; then + local highest_remote=$(get_highest_from_remote_refs) + local highest_branch=$(get_highest_from_branches) + if [ "$highest_remote" -gt "$highest_branch" ]; then + highest_branch=$highest_remote + fi + else + git fetch --all --prune >/dev/null 2>&1 || true + local highest_branch=$(get_highest_from_branches) + fi + + local highest_spec=$(get_highest_from_specs "$specs_dir") + + local max_num=$highest_branch + if [ "$highest_spec" -gt "$max_num" ]; then + max_num=$highest_spec + fi + + echo $((max_num + 1)) +} + +# Function to clean and format a branch name +clean_branch_name() { + local name="$1" + echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' +} + +# --------------------------------------------------------------------------- +# Source common.sh for resolve_template, json_escape, get_repo_root, has_git. +# +# Search locations in priority order: +# 1. .specify/scripts/bash/common.sh under the project root (installed project) +# 2. scripts/bash/common.sh under the project root (source checkout fallback) +# 3. git-common.sh next to this script (minimal fallback — lacks resolve_template) +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Find project root by walking up from the script location +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +_common_loaded=false +_PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true + +if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then + source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" + _common_loaded=true +elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then + source "$_PROJECT_ROOT/scripts/bash/common.sh" + _common_loaded=true +elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then + source "$SCRIPT_DIR/git-common.sh" + _common_loaded=true +fi + +if [ "$_common_loaded" != "true" ]; then + echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2 + exit 1 +fi + +# Resolve repository root +if type get_repo_root >/dev/null 2>&1; then + REPO_ROOT=$(get_repo_root) +elif git rev-parse --show-toplevel >/dev/null 2>&1; then + REPO_ROOT=$(git rev-parse --show-toplevel) +elif [ -n "$_PROJECT_ROOT" ]; then + REPO_ROOT="$_PROJECT_ROOT" +else + echo "Error: Could not determine repository root." >&2 + exit 1 +fi + +# Check if git is available at this repo root +if type has_git >/dev/null 2>&1; then + if has_git "$REPO_ROOT"; then + HAS_GIT=true + else + HAS_GIT=false + fi +elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + HAS_GIT=true +else + HAS_GIT=false +fi + +cd "$REPO_ROOT" + +SPECS_DIR="$REPO_ROOT/specs" + +# Function to generate branch name with stop word filtering +generate_branch_name() { + local description="$1" + + local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$" + + local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') + + local meaningful_words=() + for word in $clean_name; do + [ -z "$word" ] && continue + if ! echo "$word" | grep -qiE "$stop_words"; then + if [ ${#word} -ge 3 ]; then + meaningful_words+=("$word") + elif echo "$description" | grep -qw -- "${word^^}"; then + meaningful_words+=("$word") + fi + fi + done + + if [ ${#meaningful_words[@]} -gt 0 ]; then + local max_words=3 + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi + + local result="" + local count=0 + for word in "${meaningful_words[@]}"; do + if [ $count -ge $max_words ]; then break; fi + if [ -n "$result" ]; then result="$result-"; fi + result="$result$word" + count=$((count + 1)) + done + echo "$result" + else + local cleaned=$(clean_branch_name "$description") + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' + fi +} + +# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) +if [ -n "${GIT_BRANCH_NAME:-}" ]; then + BRANCH_NAME="$GIT_BRANCH_NAME" + # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix + # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern + if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}') + BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" + elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+') + BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" + else + FEATURE_NUM="$BRANCH_NAME" + BRANCH_SUFFIX="$BRANCH_NAME" + fi +else + # Generate branch name + if [ -n "$SHORT_NAME" ]; then + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") + else + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") + fi + + # Warn if --number and --timestamp are both specified + if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" + fi + + # Determine branch prefix + if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + else + if [ -z "$BRANCH_NUMBER" ]; then + if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + elif [ "$DRY_RUN" = true ]; then + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + elif [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi + fi + + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + fi +fi + +# GitHub enforces a 244-byte limit on branch names +MAX_BRANCH_LENGTH=244 +_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; } +BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME") +if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then + >&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes." + exit 1 +elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then + PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) + MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) + + TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) + TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') + + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" + BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" + >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" +fi + +if [ "$DRY_RUN" != true ]; then + if [ "$HAS_GIT" = true ]; then + branch_create_error="" + if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then + current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if git branch --list "$BRANCH_NAME" | grep -q .; then + if [ "$ALLOW_EXISTING" = true ]; then + if [ "$current_branch" = "$BRANCH_NAME" ]; then + : + elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then + >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + if [ -n "$switch_branch_error" ]; then + >&2 printf '%s\n' "$switch_branch_error" + fi + exit 1 + fi + elif [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + exit 1 + else + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + exit 1 + fi + else + >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'." + if [ -n "$branch_create_error" ]; then + >&2 printf '%s\n' "$branch_create_error" + else + >&2 echo "Please check your git configuration and try again." + fi + exit 1 + fi + fi + else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" + fi + + printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 +fi + +if $JSON_MODE; then + if command -v jq >/dev/null 2>&1; then + if [ "$DRY_RUN" = true ]; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}' + else + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}' + fi + else + if type json_escape >/dev/null 2>&1; then + _je_branch=$(json_escape "$BRANCH_NAME") + _je_num=$(json_escape "$FEATURE_NUM") + else + _je_branch="$BRANCH_NAME" + _je_num="$FEATURE_NUM" + fi + if [ "$DRY_RUN" = true ]; then + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num" + else + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num" + fi + fi +else + echo "BRANCH_NAME: $BRANCH_NAME" + echo "FEATURE_NUM: $FEATURE_NUM" + if [ "$DRY_RUN" != true ]; then + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" + fi +fi diff --git a/.specify/extensions/git/scripts/bash/git-common.sh b/.specify/extensions/git/scripts/bash/git-common.sh new file mode 100755 index 00000000..b78356d1 --- /dev/null +++ b/.specify/extensions/git/scripts/bash/git-common.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Git-specific common functions for the git extension. +# Extracted from scripts/bash/common.sh — contains only git-specific +# branch validation and detection logic. + +# Check if we have git available at the repo root +has_git() { + local repo_root="${1:-$(pwd)}" + { [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \ + command -v git >/dev/null 2>&1 && \ + git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 +} + +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +spec_kit_effective_branch_name() { + local raw="$1" + if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then + printf '%s\n' "${BASH_REMATCH[2]}" + else + printf '%s\n' "$raw" + fi +} + +# Validate that a branch name matches the expected feature branch pattern. +# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats. +# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization. +check_feature_branch() { + local raw="$1" + local has_git_repo="$2" + + # For non-git repos, we can't enforce branch naming but still provide output + if [[ "$has_git_repo" != "true" ]]; then + echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 + return 0 + fi + + local branch + branch=$(spec_kit_effective_branch_name "$raw") + + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + local is_sequential=false + if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then + is_sequential=true + fi + if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 + echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 + return 1 + fi + + return 0 +} diff --git a/.specify/extensions/git/scripts/bash/initialize-repo.sh b/.specify/extensions/git/scripts/bash/initialize-repo.sh new file mode 100755 index 00000000..296e363b --- /dev/null +++ b/.specify/extensions/git/scripts/bash/initialize-repo.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Git extension: initialize-repo.sh +# Initialize a Git repository with an initial commit. +# Customizable — replace this script to add .gitignore templates, +# default branch config, git-flow, LFS, signing, etc. + +set -e + +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Find project root +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)" +cd "$REPO_ROOT" + +# Read commit message from extension config, fall back to default +COMMIT_MSG="[Spec Kit] Initial commit" +_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml" +if [ -f "$_config_file" ]; then + _msg=$(grep '^init_commit_message:' "$_config_file" 2>/dev/null | sed 's/^init_commit_message:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//') + if [ -n "$_msg" ]; then + COMMIT_MSG="$_msg" + fi +fi + +# Check if git is available +if ! command -v git >/dev/null 2>&1; then + echo "[specify] Warning: Git not found; skipped repository initialization" >&2 + exit 0 +fi + +# Check if already a git repo +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[specify] Git repository already initialized; skipping" >&2 + exit 0 +fi + +# Initialize +_git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_out" >&2; exit 1; } +_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; } +_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; } + +echo "✓ Git repository initialized" >&2 diff --git a/.specify/extensions/git/scripts/powershell/auto-commit.ps1 b/.specify/extensions/git/scripts/powershell/auto-commit.ps1 new file mode 100644 index 00000000..4a8b0e00 --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/auto-commit.ps1 @@ -0,0 +1,169 @@ +#!/usr/bin/env pwsh +# Git extension: auto-commit.ps1 +# Automatically commit changes after a Spec Kit command completes. +# Checks per-command config keys in git-config.yml before committing. +# +# Usage: auto-commit.ps1 +# e.g.: auto-commit.ps1 after_specify +param( + [Parameter(Position = 0, Mandatory = $true)] + [string]$EventName +) +$ErrorActionPreference = 'Stop' + +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot +if (-not $repoRoot) { $repoRoot = Get-Location } +Set-Location $repoRoot + +# Check if git is available +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Warning "[specify] Warning: Git not found; skipped auto-commit" + exit 0 +} + +# Temporarily relax ErrorActionPreference so git stderr warnings +# (e.g. CRLF notices on Windows) do not become terminating errors. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + git rev-parse --is-inside-work-tree 2>$null | Out-Null + $isRepo = $LASTEXITCODE -eq 0 +} finally { + $ErrorActionPreference = $savedEAP +} +if (-not $isRepo) { + Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit" + exit 0 +} + +# Read per-command config from git-config.yml +$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml" +$enabled = $false +$commitMsg = "" + +if (Test-Path $configFile) { + # Parse YAML to find auto_commit section + $inAutoCommit = $false + $inEvent = $false + $defaultEnabled = $false + + foreach ($line in Get-Content $configFile) { + # Detect auto_commit: section + if ($line -match '^auto_commit:') { + $inAutoCommit = $true + $inEvent = $false + continue + } + + # Exit auto_commit section on next top-level key + if ($inAutoCommit -and $line -match '^[a-z]') { + break + } + + if ($inAutoCommit) { + # Check default key + if ($line -match '^\s+default:\s*(.+)$') { + $val = $matches[1].Trim().ToLower() + if ($val -eq 'true') { $defaultEnabled = $true } + } + + # Detect our event subsection + if ($line -match "^\s+${EventName}:") { + $inEvent = $true + continue + } + + # Inside our event subsection + if ($inEvent) { + # Exit on next sibling key (2-space indent, not 4+) + if ($line -match '^\s{2}[a-z]' -and $line -notmatch '^\s{4}') { + $inEvent = $false + continue + } + if ($line -match '\s+enabled:\s*(.+)$') { + $val = $matches[1].Trim().ToLower() + if ($val -eq 'true') { $enabled = $true } + if ($val -eq 'false') { $enabled = $false } + } + if ($line -match '\s+message:\s*(.+)$') { + $commitMsg = $matches[1].Trim() -replace '^["'']' -replace '["'']$' + } + } + } + } + + # If event-specific key not found, use default + if (-not $enabled -and $defaultEnabled) { + $hasEventKey = Select-String -Path $configFile -Pattern "^\s*${EventName}:" -Quiet + if (-not $hasEventKey) { + $enabled = $true + } + } +} else { + # No config file — auto-commit disabled by default + exit 0 +} + +if (-not $enabled) { + exit 0 +} + +# Check if there are changes to commit +# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE + git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE + $untracked = git ls-files --others --exclude-standard 2>$null +} finally { + $ErrorActionPreference = $savedEAP +} + +if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) { + Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray + exit 0 +} + +# Derive a human-readable command name from the event +$commandName = $EventName -replace '^after_', '' -replace '^before_', '' +$phase = if ($EventName -match '^before_') { 'before' } else { 'after' } + +# Use custom message if configured, otherwise default +if (-not $commitMsg) { + $commitMsg = "[Spec Kit] Auto-commit $phase $commandName" +} + +# Stage and commit +# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate, +# while still allowing redirected error output to be captured for diagnostics. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + $out = git add . 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" } + $out = git commit -q -m $commitMsg 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" } +} catch { + Write-Warning "[specify] Error: $_" + exit 1 +} finally { + $ErrorActionPreference = $savedEAP +} + +Write-Host "[OK] Changes committed $phase $commandName" diff --git a/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 b/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 new file mode 100644 index 00000000..b579f051 --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -0,0 +1,403 @@ +#!/usr/bin/env pwsh +# Git extension: create-new-feature.ps1 +# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout. +# Sources common.ps1 from the project's installed scripts, falling back to +# git-common.ps1 for minimal git helpers. +[CmdletBinding()] +param( + [switch]$Json, + [switch]$AllowExistingBranch, + [switch]$DryRun, + [string]$ShortName, + [Parameter()] + [long]$Number = 0, + [switch]$Timestamp, + [switch]$Help, + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [string[]]$FeatureDescription +) +$ErrorActionPreference = 'Stop' + +if ($Help) { + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Host "" + Write-Host "Options:" + Write-Host " -Json Output in JSON format" + Write-Host " -DryRun Compute branch name without creating the branch" + Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" + Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" + Write-Host " -Number N Specify branch number manually (overrides auto-detection)" + Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + Write-Host " -Help Show this help message" + Write-Host "" + Write-Host "Environment variables:" + Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" + Write-Host "" + exit 0 +} + +if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + exit 1 +} + +$featureDesc = ($FeatureDescription -join ' ').Trim() + +if ([string]::IsNullOrWhiteSpace($featureDesc)) { + Write-Error "Error: Feature description cannot be empty or contain only whitespace" + exit 1 +} + +function Get-HighestNumberFromSpecs { + param([string]$SpecsDir) + + [long]$highest = 0 + if (Test-Path $SpecsDir) { + Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { + if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } + } + } + } + return $highest +} + +function Get-HighestNumberFromNames { + param([string[]]$Names) + + [long]$highest = 0 + foreach ($name in $Names) { + if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } + } + } + return $highest +} + +function Get-HighestNumberFromBranches { + param() + + try { + $branches = git branch -a 2>$null + if ($LASTEXITCODE -eq 0 -and $branches) { + $cleanNames = $branches | ForEach-Object { + $_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' + } + return Get-HighestNumberFromNames -Names $cleanNames + } + } catch { + Write-Verbose "Could not check Git branches: $_" + } + return 0 +} + +function Get-HighestNumberFromRemoteRefs { + [long]$highest = 0 + try { + $remotes = git remote 2>$null + if ($remotes) { + foreach ($remote in $remotes) { + $env:GIT_TERMINAL_PROMPT = '0' + $refs = git ls-remote --heads $remote 2>$null + $env:GIT_TERMINAL_PROMPT = $null + if ($LASTEXITCODE -eq 0 -and $refs) { + $refNames = $refs | ForEach-Object { + if ($_ -match 'refs/heads/(.+)$') { $matches[1] } + } | Where-Object { $_ } + $remoteHighest = Get-HighestNumberFromNames -Names $refNames + if ($remoteHighest -gt $highest) { $highest = $remoteHighest } + } + } + } + } catch { + Write-Verbose "Could not query remote refs: $_" + } + return $highest +} + +function Get-NextBranchNumber { + param( + [string]$SpecsDir, + [switch]$SkipFetch + ) + + if ($SkipFetch) { + $highestBranch = Get-HighestNumberFromBranches + $highestRemote = Get-HighestNumberFromRemoteRefs + $highestBranch = [Math]::Max($highestBranch, $highestRemote) + } else { + try { + git fetch --all --prune 2>$null | Out-Null + } catch { } + $highestBranch = Get-HighestNumberFromBranches + } + + $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir + $maxNum = [Math]::Max($highestBranch, $highestSpec) + return $maxNum + 1 +} + +function ConvertTo-CleanBranchName { + param([string]$Name) + return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' +} + +# --------------------------------------------------------------------------- +# Source common.ps1 from the project's installed scripts. +# Search locations in priority order: +# 1. .specify/scripts/powershell/common.ps1 under the project root +# 2. scripts/powershell/common.ps1 under the project root (source checkout) +# 3. git-common.ps1 next to this script (minimal fallback) +# --------------------------------------------------------------------------- +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot +$commonLoaded = $false + +if ($projectRoot) { + $candidates = @( + (Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"), + (Join-Path $projectRoot "scripts/powershell/common.ps1") + ) + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + . $candidate + $commonLoaded = $true + break + } + } +} + +if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) { + . "$PSScriptRoot/git-common.ps1" + $commonLoaded = $true +} + +if (-not $commonLoaded) { + throw "Unable to locate common script file. Please ensure the Specify core scripts are installed." +} + +# Resolve repository root +if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { + $repoRoot = Get-RepoRoot +} elseif ($projectRoot) { + $repoRoot = $projectRoot +} else { + throw "Could not determine repository root." +} + +# Check if git is available +if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { + # Call without parameters for compatibility with core common.ps1 (no -RepoRoot param) + # and git-common.ps1 (has -RepoRoot param with default). + $hasGit = Test-HasGit +} else { + try { + git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null + $hasGit = ($LASTEXITCODE -eq 0) + } catch { + $hasGit = $false + } +} + +Set-Location $repoRoot + +$specsDir = Join-Path $repoRoot 'specs' + +function Get-BranchName { + param([string]$Description) + + $stopWords = @( + 'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from', + 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', + 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall', + 'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their', + 'want', 'need', 'add', 'get', 'set' + ) + + $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' + $words = $cleanName -split '\s+' | Where-Object { $_ } + + $meaningfulWords = @() + foreach ($word in $words) { + if ($stopWords -contains $word) { continue } + if ($word.Length -ge 3) { + $meaningfulWords += $word + } elseif ($Description -match "\b$($word.ToUpper())\b") { + $meaningfulWords += $word + } + } + + if ($meaningfulWords.Count -gt 0) { + $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } + $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-' + return $result + } else { + $result = ConvertTo-CleanBranchName -Name $Description + $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3 + return [string]::Join('-', $fallbackWords) + } +} + +# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) +if ($env:GIT_BRANCH_NAME) { + $branchName = $env:GIT_BRANCH_NAME + # Check 244-byte limit (UTF-8) for override names + $branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName) + if ($branchNameUtf8ByteCount -gt 244) { + throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name." + } + # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix + # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern + if ($branchName -match '^(\d{8}-\d{6})-') { + $featureNum = $matches[1] + } elseif ($branchName -match '^(\d+)-') { + $featureNum = $matches[1] + } else { + $featureNum = $branchName + } +} else { + if ($ShortName) { + $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName + } else { + $branchSuffix = Get-BranchName -Description $featureDesc + } + + if ($Timestamp -and $Number -ne 0) { + Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" + $Number = 0 + } + + if ($Timestamp) { + $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' + $branchName = "$featureNum-$branchSuffix" + } else { + if ($Number -eq 0) { + if ($DryRun -and $hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch + } elseif ($DryRun) { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } elseif ($hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir + } else { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } + } + + $featureNum = ('{0:000}' -f $Number) + $branchName = "$featureNum-$branchSuffix" + } +} + +$maxBranchLength = 244 +if ($branchName.Length -gt $maxBranchLength) { + $prefixLength = $featureNum.Length + 1 + $maxSuffixLength = $maxBranchLength - $prefixLength + + $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) + $truncatedSuffix = $truncatedSuffix -replace '-$', '' + + $originalBranchName = $branchName + $branchName = "$featureNum-$truncatedSuffix" + + Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" + Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" + Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" +} + +if (-not $DryRun) { + if ($hasGit) { + $branchCreated = $false + $branchCreateError = '' + try { + $branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String + if ($LASTEXITCODE -eq 0) { + $branchCreated = $true + } + } catch { + $branchCreateError = $_.Exception.Message + } + + if (-not $branchCreated) { + $currentBranch = '' + try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {} + $existingBranch = git branch --list $branchName 2>$null + if ($existingBranch) { + if ($AllowExistingBranch) { + if ($currentBranch -eq $branchName) { + # Already on the target branch + } else { + $switchBranchError = git checkout -q $branchName 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + if ($switchBranchError) { + Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())" + } else { + Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + } + exit 1 + } + } + } elseif ($Timestamp) { + Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." + exit 1 + } else { + Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." + exit 1 + } + } else { + if ($branchCreateError) { + Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())" + } else { + Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." + } + exit 1 + } + } + } else { + if ($Json) { + [Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName") + } else { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" + } + } + + $env:SPECIFY_FEATURE = $branchName +} + +if ($Json) { + $obj = [PSCustomObject]@{ + BRANCH_NAME = $branchName + FEATURE_NUM = $featureNum + HAS_GIT = $hasGit + } + if ($DryRun) { + $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true + } + $obj | ConvertTo-Json -Compress +} else { + Write-Output "BRANCH_NAME: $branchName" + Write-Output "FEATURE_NUM: $featureNum" + Write-Output "HAS_GIT: $hasGit" + if (-not $DryRun) { + Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" + } +} diff --git a/.specify/extensions/git/scripts/powershell/git-common.ps1 b/.specify/extensions/git/scripts/powershell/git-common.ps1 new file mode 100644 index 00000000..82210000 --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/git-common.ps1 @@ -0,0 +1,51 @@ +#!/usr/bin/env pwsh +# Git-specific common functions for the git extension. +# Extracted from scripts/powershell/common.ps1 — contains only git-specific +# branch validation and detection logic. + +function Test-HasGit { + param([string]$RepoRoot = (Get-Location)) + try { + if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false } + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false } + git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null + return ($LASTEXITCODE -eq 0) + } catch { + return $false + } +} + +function Get-SpecKitEffectiveBranchName { + param([string]$Branch) + if ($Branch -match '^([^/]+)/([^/]+)$') { + return $Matches[2] + } + return $Branch +} + +function Test-FeatureBranch { + param( + [string]$Branch, + [bool]$HasGit = $true + ) + + # For non-git repos, we can't enforce branch naming but still provide output + if (-not $HasGit) { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation" + return $true + } + + $raw = $Branch + $Branch = Get-SpecKitEffectiveBranchName $raw + + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') + $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) + if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') { + [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") + [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name") + return $false + } + return $true +} diff --git a/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 b/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 new file mode 100644 index 00000000..324240a3 --- /dev/null +++ b/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 @@ -0,0 +1,69 @@ +#!/usr/bin/env pwsh +# Git extension: initialize-repo.ps1 +# Initialize a Git repository with an initial commit. +# Customizable — replace this script to add .gitignore templates, +# default branch config, git-flow, LFS, signing, etc. +$ErrorActionPreference = 'Stop' + +# Find project root +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot +if (-not $repoRoot) { $repoRoot = Get-Location } +Set-Location $repoRoot + +# Read commit message from extension config, fall back to default +$commitMsg = "[Spec Kit] Initial commit" +$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml" +if (Test-Path $configFile) { + foreach ($line in Get-Content $configFile) { + if ($line -match '^init_commit_message:\s*(.+)$') { + $val = $matches[1].Trim() -replace '^["'']' -replace '["'']$' + if ($val) { $commitMsg = $val } + break + } + } +} + +# Check if git is available +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Warning "[specify] Warning: Git not found; skipped repository initialization" + exit 0 +} + +# Check if already a git repo +try { + git rev-parse --is-inside-work-tree 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Warning "[specify] Git repository already initialized; skipping" + exit 0 + } +} catch { } + +# Initialize +try { + $out = git init -q 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git init failed: $out" } + $out = git add . 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" } + $out = git commit --allow-empty -q -m $commitMsg 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" } +} catch { + Write-Warning "[specify] Error: $_" + exit 1 +} + +Write-Host "✓ Git repository initialized" diff --git a/.specify/init-options.json b/.specify/init-options.json new file mode 100644 index 00000000..7115ce9e --- /dev/null +++ b/.specify/init-options.json @@ -0,0 +1,10 @@ +{ + "ai": "copilot", + "branch_numbering": "sequential", + "context_file": ".github/copilot-instructions.md", + "here": true, + "integration": "copilot", + "preset": null, + "script": "sh", + "speckit_version": "0.7.4" +} \ No newline at end of file diff --git a/.specify/integration.json b/.specify/integration.json new file mode 100644 index 00000000..d43cd968 --- /dev/null +++ b/.specify/integration.json @@ -0,0 +1,4 @@ +{ + "integration": "copilot", + "version": "0.7.4" +} diff --git a/.specify/integrations/copilot.manifest.json b/.specify/integrations/copilot.manifest.json new file mode 100644 index 00000000..14758317 --- /dev/null +++ b/.specify/integrations/copilot.manifest.json @@ -0,0 +1,25 @@ +{ + "integration": "copilot", + "version": "0.7.4", + "installed_at": "2026-04-22T21:58:02.962169+00:00", + "files": { + ".github/agents/speckit.analyze.agent.md": "699032fdd49afe31d23c7191f3fe7bcb1d14b081fbc94c2287e6ba3a57574fda", + ".github/agents/speckit.checklist.agent.md": "d7d691689fe45427c868dcf18ade4df500f0c742a6c91923fefba405d6466dde", + ".github/agents/speckit.clarify.agent.md": "0cc766dcc5cab233ccdf3bc4cfb5759a6d7d1e13e29f611083046f818f5812bb", + ".github/agents/speckit.constitution.agent.md": "58d35eb026f56bb7364d91b8b0382d5dd1249ded6c1449a2b69546693afb85f7", + ".github/agents/speckit.implement.agent.md": "83628415c86ba487b3a083c7a2c0f016c9073abd02c1c7f4a30cff949b6602c0", + ".github/agents/speckit.plan.agent.md": "2ad128b81ccd8f5bfa78b3b43101f377dfddd8f800fa0856f85bf53b1489b783", + ".github/agents/speckit.specify.agent.md": "5bbb5270836cc9a3286ce3ed96a500f3d383a54abb06aa11b01a2d2f76dbf39b", + ".github/agents/speckit.tasks.agent.md": "a58886f29f75e1a14840007772ddd954742aafb3e03d9d1231bee033e6c1626b", + ".github/agents/speckit.taskstoissues.agent.md": "e84794f7a839126defb364ca815352c5c2b2d20db2d6da399fa53e4ddbb7b3ee", + ".github/prompts/speckit.analyze.prompt.md": "bb93dbbafa96d07b7cd07fc7061d8adb0c6b26cb772a52d0dce263b1ca2b9b77", + ".github/prompts/speckit.checklist.prompt.md": "c3aea7526c5cbfd8665acc9508ad5a9a3f71e91a63c36be7bed13a834c3a683c", + ".github/prompts/speckit.clarify.prompt.md": "ce79b3437ca918d46ac858eb4b8b44d3b0a02c563660c60d94c922a7b5d8d4f4", + ".github/prompts/speckit.constitution.prompt.md": "38f937279de14387601422ddfda48365debdbaf47b2d513527b8f6d8a27d499d", + ".github/prompts/speckit.implement.prompt.md": "5053a17fb9238338c63b898ee9c80b2cb4ad1a90c6071fe3748de76864ac6a80", + ".github/prompts/speckit.plan.prompt.md": "2098dae6bd9277335f31cb150b78bfb1de539c0491798e5cfe382c89ab0bcd0e", + ".github/prompts/speckit.specify.prompt.md": "7b2cc4dc6462da1c96df46bac4f60e53baba3097f4b24ac3f9b684194458aa98", + ".github/prompts/speckit.tasks.prompt.md": "88fc57c289f99d5e9d35c255f3e2683f73ecb0a5155dcb4d886f82f52b11841f", + ".github/prompts/speckit.taskstoissues.prompt.md": "2f9636d4f312a1470f000747cb62677fec0655d8b4e2357fa4fbf238965fa66d" + } +} diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json new file mode 100644 index 00000000..863baec0 --- /dev/null +++ b/.specify/integrations/speckit.manifest.json @@ -0,0 +1,8 @@ +{ + "integration": "speckit", + "version": "0.7.4", + "installed_at": "2026-04-22T21:58:02.965809+00:00", + "files": { + ".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3" + } +} diff --git a/.specify/templates/constitution-template.md b/.specify/templates/constitution-template.md new file mode 100644 index 00000000..a4670ff4 --- /dev/null +++ b/.specify/templates/constitution-template.md @@ -0,0 +1,50 @@ +# [PROJECT_NAME] Constitution + + +## Core Principles + +### [PRINCIPLE_1_NAME] + +[PRINCIPLE_1_DESCRIPTION] + + +### [PRINCIPLE_2_NAME] + +[PRINCIPLE_2_DESCRIPTION] + + +### [PRINCIPLE_3_NAME] + +[PRINCIPLE_3_DESCRIPTION] + + +### [PRINCIPLE_4_NAME] + +[PRINCIPLE_4_DESCRIPTION] + + +### [PRINCIPLE_5_NAME] + +[PRINCIPLE_5_DESCRIPTION] + + +## [SECTION_2_NAME] + + +[SECTION_2_CONTENT] + + +## [SECTION_3_NAME] + + +[SECTION_3_CONTENT] + + +## Governance + + +[GOVERNANCE_RULES] + + +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] + diff --git a/.specify/workflows/speckit/workflow.yml b/.specify/workflows/speckit/workflow.yml new file mode 100644 index 00000000..bf184510 --- /dev/null +++ b/.specify/workflows/speckit/workflow.yml @@ -0,0 +1,63 @@ +schema_version: "1.0" +workflow: + id: "speckit" + name: "Full SDD Cycle" + version: "1.0.0" + author: "GitHub" + description: "Runs specify → plan → tasks → implement with review gates" + +requires: + speckit_version: ">=0.7.2" + integrations: + any: ["copilot", "claude", "gemini"] + +inputs: + spec: + type: string + required: true + prompt: "Describe what you want to build" + integration: + type: string + default: "copilot" + prompt: "Integration to use (e.g. claude, copilot, gemini)" + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] + +steps: + - id: specify + command: speckit.specify + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-spec + type: gate + message: "Review the generated spec before planning." + options: [approve, reject] + on_reject: abort + + - id: plan + command: speckit.plan + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-plan + type: gate + message: "Review the plan before generating tasks." + options: [approve, reject] + on_reject: abort + + - id: tasks + command: speckit.tasks + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: implement + command: speckit.implement + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" diff --git a/.specify/workflows/workflow-registry.json b/.specify/workflows/workflow-registry.json new file mode 100644 index 00000000..bda953d9 --- /dev/null +++ b/.specify/workflows/workflow-registry.json @@ -0,0 +1,13 @@ +{ + "schema_version": "1.0", + "workflows": { + "speckit": { + "name": "Full SDD Cycle", + "version": "1.0.0", + "description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates", + "source": "bundled", + "installed_at": "2026-04-22T21:58:03.039039+00:00", + "updated_at": "2026-04-22T21:58:03.039046+00:00" + } + } +} \ No newline at end of file -- 2.45.2 From 421261a517548ececbad1b71ba48c727cd90d4fe Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 23 Apr 2026 07:29:05 +0000 Subject: [PATCH 04/36] feat: implement finding outcome taxonomy (#267) ## Summary - implement the finding outcome taxonomy end-to-end with canonical resolve, close, reopen, and verification semantics - align finding UI, filters, audit metadata, review summaries, and export/read-model consumers to the shared outcome semantics - add focused Pest coverage and complete the spec artifacts for feature 231 ## Details - manual resolve is limited to the canonical `remediated` outcome - close and reopen flows now use bounded canonical reasons - trusted system clear and reopen distinguish verified-clear from verification-failed and recurrence paths - duplicate lifecycle backfill now closes findings canonically as `duplicate` - accepted-risk recording now uses the canonical `accepted_risk` reason - finding detail and list surfaces now expose terminal outcome and verification summaries - review, snapshot, and review-pack consumers now propagate the same outcome buckets ## Filament / Platform Contract - Livewire v4.0+ compatibility remains intact - provider registration is unchanged and remains in `bootstrap/providers.php` - no new globally searchable resource was introduced; `FindingResource` still has a View page and `TenantReviewResource` remains globally searchable false - lifecycle mutations still run through confirmed Filament actions with capability enforcement - no new asset family was added; the existing `filament:assets` deploy step is unchanged ## Verification - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowServiceTest.php tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Models/FindingResolvedTest.php tests/Unit/Findings/FindingWorkflowServiceTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/TenantReview/TenantReviewRegisterTest.php tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php` - browser smoke: `/admin/findings/my-work` -> finding detail resolve flow -> queue regression check passed ## Notes - this commit also includes the existing `.github/agents/copilot-instructions.md` workspace change that was already present in the worktree when all changes were committed Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/267 --- .github/agents/copilot-instructions.md | 5 +- .../Filament/Pages/Reviews/ReviewRegister.php | 22 +- .../Filament/Resources/FindingResource.php | 238 +++++++++-- .../Resources/TenantReviewResource.php | 26 +- .../app/Jobs/BackfillFindingLifecycleJob.php | 9 +- ...dingLifecycleTenantIntoWorkspaceRunJob.php | 9 +- apps/platform/app/Models/Finding.php | 133 ++++++ .../Evidence/EvidenceSnapshotService.php | 6 + .../Sources/FindingsSummarySource.php | 37 ++ .../Findings/FindingExceptionService.php | 2 +- .../FindingRiskGovernanceResolver.php | 51 ++- .../Findings/FindingWorkflowService.php | 42 +- .../app/Services/ReviewPackService.php | 12 + .../TenantReviews/TenantReviewComposer.php | 6 + .../TenantReviewSectionFactory.php | 11 + .../Support/Filament/FilterOptionCatalog.php | 17 + .../Findings/FindingOutcomeSemantics.php | 203 +++++++++ .../GovernanceActionCatalog.php | 31 +- .../database/factories/FindingFactory.php | 15 +- .../Baselines/BaselineCompareFindingsTest.php | 2 +- ...ndingResolvedReferencePresentationTest.php | 49 +++ .../Findings/FindingAuditBackstopTest.php | 10 +- .../Feature/Findings/FindingAuditLogTest.php | 7 +- .../FindingAutomationWorkflowTest.php | 6 +- .../Feature/Findings/FindingBackfillTest.php | 10 +- .../Findings/FindingBulkActionsTest.php | 8 +- .../FindingOutcomeSummaryReportingTest.php | 145 +++++++ .../Findings/FindingRecurrenceTest.php | 19 +- .../FindingRiskGovernanceProjectionTest.php | 23 + .../FindingWorkflowConcurrencyTest.php | 4 +- .../FindingWorkflowRowActionsTest.php | 10 +- .../Findings/FindingWorkflowServiceTest.php | 131 +++++- .../FindingWorkflowViewActionsTest.php | 18 +- .../Findings/FindingsListFiltersTest.php | 56 ++- .../Feature/Models/FindingResolvedTest.php | 14 +- .../Findings/FindingWorkflowServiceTest.php | 4 +- .../checklists/requirements.md | 36 ++ ...ding-outcome-taxonomy.logical.openapi.yaml | 397 ++++++++++++++++++ .../data-model.md | 174 ++++++++ specs/231-finding-outcome-taxonomy/plan.md | 259 ++++++++++++ .../quickstart.md | 87 ++++ .../231-finding-outcome-taxonomy/research.md | 57 +++ specs/231-finding-outcome-taxonomy/spec.md | 276 ++++++++++++ specs/231-finding-outcome-taxonomy/tasks.md | 229 ++++++++++ 44 files changed, 2784 insertions(+), 122 deletions(-) create mode 100644 apps/platform/app/Support/Findings/FindingOutcomeSemantics.php create mode 100644 apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php create mode 100644 specs/231-finding-outcome-taxonomy/checklists/requirements.md create mode 100644 specs/231-finding-outcome-taxonomy/contracts/finding-outcome-taxonomy.logical.openapi.yaml create mode 100644 specs/231-finding-outcome-taxonomy/data-model.md create mode 100644 specs/231-finding-outcome-taxonomy/plan.md create mode 100644 specs/231-finding-outcome-taxonomy/quickstart.md create mode 100644 specs/231-finding-outcome-taxonomy/research.md create mode 100644 specs/231-finding-outcome-taxonomy/spec.md create mode 100644 specs/231-finding-outcome-taxonomy/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index aca1ec37..07db2c88 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -238,6 +238,8 @@ ## Active Technologies - 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, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger` (231-finding-outcome-taxonomy) +- PostgreSQL via existing `findings`, `finding_exceptions`, `tenant_reviews`, `stored_reports`, and audit-log tables; no schema changes planned (231-finding-outcome-taxonomy) - PHP 8.4.15 (feat/005-bulk-operations) @@ -272,10 +274,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 231-finding-outcome-taxonomy: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger` - 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` ### Pre-production compatibility check diff --git a/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php b/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php index d85a703d..fb881623 100644 --- a/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php +++ b/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php @@ -14,6 +14,7 @@ use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; +use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\FilterPresets; use App\Support\Filament\TablePaginationProfiles; @@ -352,7 +353,14 @@ private function reviewOutcomeBadgeIconColor(TenantReview $record): ?string private function reviewOutcomeDescription(TenantReview $record): ?string { - return $this->reviewOutcome($record)->primaryReason; + $primaryReason = $this->reviewOutcome($record)->primaryReason; + $findingOutcomeSummary = $this->findingOutcomeSummary($record); + + if ($findingOutcomeSummary === null) { + return $primaryReason; + } + + return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.'); } private function reviewOutcomeNextStep(TenantReview $record): string @@ -373,4 +381,16 @@ private function reviewOutcome(TenantReview $record, bool $fresh = false): Compr SurfaceCompressionContext::reviewRegister(), ); } + + private function findingOutcomeSummary(TenantReview $record): ?string + { + $summary = is_array($record->summary) ? $record->summary : []; + $outcomeCounts = $summary['finding_outcomes'] ?? []; + + if (! is_array($outcomeCounts)) { + return null; + } + + return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts); + } } diff --git a/apps/platform/app/Filament/Resources/FindingResource.php b/apps/platform/app/Filament/Resources/FindingResource.php index a223f4e0..776a1a62 100644 --- a/apps/platform/app/Filament/Resources/FindingResource.php +++ b/apps/platform/app/Filament/Resources/FindingResource.php @@ -21,6 +21,7 @@ use App\Support\Badges\BadgeRenderer; use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterPresets; +use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Filament\TablePaginationProfiles; use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\CrossResourceNavigationMatrix; @@ -156,6 +157,14 @@ public static function infolist(Schema $schema): Schema ->color(BadgeRenderer::color(BadgeDomain::FindingStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)), + TextEntry::make('finding_terminal_outcome') + ->label('Terminal outcome') + ->state(fn (Finding $record): ?string => static::terminalOutcomeLabel($record)) + ->visible(fn (Finding $record): bool => static::terminalOutcomeLabel($record) !== null), + TextEntry::make('finding_verification_state') + ->label('Verification') + ->state(fn (Finding $record): ?string => static::verificationStateLabel($record)) + ->visible(fn (Finding $record): bool => static::verificationStateLabel($record) !== null), TextEntry::make('severity') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity)) @@ -292,9 +301,15 @@ public static function infolist(Schema $schema): Schema TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'), TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'), TextEntry::make('resolved_at')->label('Resolved at')->dateTime()->placeholder('—'), - TextEntry::make('resolved_reason')->label('Resolved reason')->placeholder('—'), + TextEntry::make('resolved_reason') + ->label('Resolved reason') + ->formatStateUsing(fn (?string $state): string => static::resolveReasonLabel($state) ?? '—') + ->placeholder('—'), TextEntry::make('closed_at')->label('Closed at')->dateTime()->placeholder('—'), - TextEntry::make('closed_reason')->label('Closed/risk reason')->placeholder('—'), + TextEntry::make('closed_reason') + ->label('Closed/risk reason') + ->formatStateUsing(fn (?string $state): string => static::closeReasonLabel($state) ?? '—') + ->placeholder('—'), TextEntry::make('closed_by_user_id') ->label('Closed by') ->formatStateUsing(fn (mixed $state, Finding $record): string => $record->closedByUser?->name ?? ($state ? 'User #'.$state : '—')), @@ -726,7 +741,7 @@ public static function table(Table $table): Table ->color(BadgeRenderer::color(BadgeDomain::FindingStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)) - ->description(fn (Finding $record): string => static::primaryNarrative($record)), + ->description(fn (Finding $record): string => static::statusDescription($record)), Tables\Columns\TextColumn::make('governance_validity') ->label('Governance') ->badge() @@ -820,6 +835,14 @@ public static function table(Table $table): Table Tables\Filters\SelectFilter::make('status') ->options(FilterOptionCatalog::findingStatuses()) ->label('Status'), + Tables\Filters\SelectFilter::make('terminal_outcome') + ->label('Terminal outcome') + ->options(FilterOptionCatalog::findingTerminalOutcomes()) + ->query(fn (Builder $query, array $data): Builder => static::applyTerminalOutcomeFilter($query, $data['value'] ?? null)), + Tables\Filters\SelectFilter::make('verification_state') + ->label('Verification') + ->options(FilterOptionCatalog::findingVerificationStates()) + ->query(fn (Builder $query, array $data): Builder => static::applyVerificationStateFilter($query, $data['value'] ?? null)), Tables\Filters\SelectFilter::make('workflow_family') ->label('Workflow family') ->options(FilterOptionCatalog::findingWorkflowFamilies()) @@ -1092,16 +1115,20 @@ public static function table(Table $table): Table UiEnforcement::forBulkAction( BulkAction::make('resolve_selected') - ->label('Resolve selected') + ->label(GovernanceActionCatalog::rule('resolve_finding')->canonicalLabel.' selected') ->icon('heroicon-o-check-badge') ->color('success') ->requiresConfirmation() + ->modalHeading(GovernanceActionCatalog::rule('resolve_finding')->modalHeading) + ->modalDescription(GovernanceActionCatalog::rule('resolve_finding')->modalDescription) ->form([ - Textarea::make('resolved_reason') - ->label('Resolution reason') - ->rows(3) + Select::make('resolved_reason') + ->label('Resolution outcome') + ->options(static::resolveReasonOptions()) + ->helperText('Use the canonical manual remediation outcome. Trusted verification is recorded later by system evidence.') + ->native(false) ->required() - ->maxLength(255), + ->selectablePlaceholder(false), ]) ->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void { $tenant = static::resolveTenantContextForCurrentPanel(); @@ -1145,7 +1172,7 @@ public static function table(Table $table): Table } } - $body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's').'.'; + $body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's')." pending verification."; if ($skippedCount > 0) { $body .= " Skipped {$skippedCount}."; } @@ -1167,18 +1194,20 @@ public static function table(Table $table): Table UiEnforcement::forBulkAction( BulkAction::make('close_selected') - ->label('Close selected') + ->label(GovernanceActionCatalog::rule('close_finding')->canonicalLabel.' selected') ->icon('heroicon-o-x-circle') ->color('warning') ->requiresConfirmation() ->modalHeading(GovernanceActionCatalog::rule('close_finding')->modalHeading) ->modalDescription(GovernanceActionCatalog::rule('close_finding')->modalDescription) ->form([ - Textarea::make('closed_reason') + Select::make('closed_reason') ->label('Close reason') - ->rows(3) + ->options(static::closeReasonOptions()) + ->helperText('Use the canonical administrative closure outcome for this finding.') + ->native(false) ->required() - ->maxLength(255), + ->selectablePlaceholder(false), ]) ->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void { $tenant = static::resolveTenantContextForCurrentPanel(); @@ -1448,24 +1477,30 @@ public static function assignAction(): Actions\Action public static function resolveAction(): Actions\Action { + $rule = GovernanceActionCatalog::rule('resolve_finding'); + return UiEnforcement::forAction( Actions\Action::make('resolve') - ->label('Resolve') + ->label($rule->canonicalLabel) ->icon('heroicon-o-check-badge') ->color('success') ->visible(fn (Finding $record): bool => $record->hasOpenStatus()) ->requiresConfirmation() + ->modalHeading($rule->modalHeading) + ->modalDescription($rule->modalDescription) ->form([ - Textarea::make('resolved_reason') - ->label('Resolution reason') - ->rows(3) + Select::make('resolved_reason') + ->label('Resolution outcome') + ->options(static::resolveReasonOptions()) + ->helperText('Use the canonical manual remediation outcome. Trusted verification is recorded later by system evidence.') + ->native(false) ->required() - ->maxLength(255), + ->selectablePlaceholder(false), ]) - ->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void { + ->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void { static::runWorkflowMutation( record: $record, - successTitle: 'Finding resolved', + successTitle: $rule->successTitle, callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->resolve( $finding, $tenant, @@ -1495,11 +1530,13 @@ public static function closeAction(): Actions\Action ->modalHeading($rule->modalHeading) ->modalDescription($rule->modalDescription) ->form([ - Textarea::make('closed_reason') + Select::make('closed_reason') ->label('Close reason') - ->rows(3) + ->options(static::closeReasonOptions()) + ->helperText('Use the canonical administrative closure outcome for this finding.') + ->native(false) ->required() - ->maxLength(255), + ->selectablePlaceholder(false), ]) ->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void { static::runWorkflowMutation( @@ -1694,12 +1731,17 @@ public static function reopenAction(): Actions\Action ->modalHeading($rule->modalHeading) ->modalDescription($rule->modalDescription) ->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status)) + ->fillForm([ + 'reopen_reason' => Finding::REOPEN_REASON_MANUAL_REASSESSMENT, + ]) ->form([ - Textarea::make('reopen_reason') + Select::make('reopen_reason') ->label('Reopen reason') - ->rows(3) + ->options(static::reopenReasonOptions()) + ->helperText('Use the canonical reopen reason that best describes why this finding needs active workflow again.') + ->native(false) ->required() - ->maxLength(255), + ->selectablePlaceholder(false), ]) ->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void { static::runWorkflowMutation( @@ -2138,6 +2180,150 @@ private static function governanceValidityState(Finding $finding): ?string ->resolveGovernanceValidity($finding, static::resolvedFindingException($finding)); } + private static function findingOutcomeSemantics(): FindingOutcomeSemantics + { + return app(FindingOutcomeSemantics::class); + } + + /** + * @return array{ + * terminal_outcome_key: ?string, + * label: ?string, + * verification_state: string, + * verification_label: ?string, + * report_bucket: ?string + * } + */ + private static function findingOutcome(Finding $finding): array + { + return static::findingOutcomeSemantics()->describe($finding); + } + + /** + * @return array + */ + private static function resolveReasonOptions(): array + { + return [ + Finding::RESOLVE_REASON_REMEDIATED => 'Remediated', + ]; + } + + /** + * @return array + */ + private static function closeReasonOptions(): array + { + return [ + Finding::CLOSE_REASON_FALSE_POSITIVE => 'False positive', + Finding::CLOSE_REASON_DUPLICATE => 'Duplicate', + Finding::CLOSE_REASON_NO_LONGER_APPLICABLE => 'No longer applicable', + ]; + } + + /** + * @return array + */ + private static function reopenReasonOptions(): array + { + return [ + Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION => 'Recurred after resolution', + Finding::REOPEN_REASON_VERIFICATION_FAILED => 'Verification failed', + Finding::REOPEN_REASON_MANUAL_REASSESSMENT => 'Manual reassessment', + ]; + } + + private static function resolveReasonLabel(?string $reason): ?string + { + return static::resolveReasonOptions()[$reason] ?? match ($reason) { + Finding::RESOLVE_REASON_NO_LONGER_DRIFTING => 'No longer drifting', + Finding::RESOLVE_REASON_PERMISSION_GRANTED => 'Permission granted', + Finding::RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY => 'Permission removed from registry', + Finding::RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED => 'Role assignment removed', + Finding::RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD => 'GA count within threshold', + default => null, + }; + } + + private static function closeReasonLabel(?string $reason): ?string + { + return static::closeReasonOptions()[$reason] ?? match ($reason) { + Finding::CLOSE_REASON_ACCEPTED_RISK => 'Accepted risk', + default => null, + }; + } + + private static function reopenReasonLabel(?string $reason): ?string + { + return static::reopenReasonOptions()[$reason] ?? null; + } + + private static function terminalOutcomeLabel(Finding $finding): ?string + { + return static::findingOutcome($finding)['label'] ?? null; + } + + private static function verificationStateLabel(Finding $finding): ?string + { + return static::findingOutcome($finding)['verification_label'] ?? null; + } + + private static function statusDescription(Finding $finding): string + { + return static::terminalOutcomeLabel($finding) ?? static::primaryNarrative($finding); + } + + private static function applyTerminalOutcomeFilter(Builder $query, mixed $value): Builder + { + if (! is_string($value) || $value === '') { + return $query; + } + + return match ($value) { + FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => $query + ->where('status', Finding::STATUS_RESOLVED) + ->whereIn('resolved_reason', Finding::manualResolveReasonKeys()), + FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => $query + ->where('status', Finding::STATUS_RESOLVED) + ->whereIn('resolved_reason', Finding::systemResolveReasonKeys()), + FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE => $query + ->where('status', Finding::STATUS_CLOSED) + ->where('closed_reason', Finding::CLOSE_REASON_FALSE_POSITIVE), + FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE => $query + ->where('status', Finding::STATUS_CLOSED) + ->where('closed_reason', Finding::CLOSE_REASON_DUPLICATE), + FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => $query + ->where('status', Finding::STATUS_CLOSED) + ->where('closed_reason', Finding::CLOSE_REASON_NO_LONGER_APPLICABLE), + FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED => $query + ->where('status', Finding::STATUS_RISK_ACCEPTED), + default => $query, + }; + } + + private static function applyVerificationStateFilter(Builder $query, mixed $value): Builder + { + if (! is_string($value) || $value === '') { + return $query; + } + + return match ($value) { + FindingOutcomeSemantics::VERIFICATION_PENDING => $query + ->where('status', Finding::STATUS_RESOLVED) + ->whereIn('resolved_reason', Finding::manualResolveReasonKeys()), + FindingOutcomeSemantics::VERIFICATION_VERIFIED => $query + ->where('status', Finding::STATUS_RESOLVED) + ->whereIn('resolved_reason', Finding::systemResolveReasonKeys()), + FindingOutcomeSemantics::VERIFICATION_NOT_APPLICABLE => $query->where(function (Builder $verificationQuery): void { + $verificationQuery + ->where('status', '!=', Finding::STATUS_RESOLVED) + ->orWhereNull('resolved_reason') + ->orWhereNotIn('resolved_reason', Finding::resolveReasonKeys()); + }), + default => $query, + }; + } + private static function primaryNarrative(Finding $finding): string { return app(FindingRiskGovernanceResolver::class) diff --git a/apps/platform/app/Filament/Resources/TenantReviewResource.php b/apps/platform/app/Filament/Resources/TenantReviewResource.php index f1e8e839..7bff7aaf 100644 --- a/apps/platform/app/Filament/Resources/TenantReviewResource.php +++ b/apps/platform/app/Filament/Resources/TenantReviewResource.php @@ -18,6 +18,7 @@ use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; +use App\Support\Findings\FindingOutcomeSemantics; use App\Support\OperationRunLinks; use App\Support\OperationRunType; use App\Support\OpsUx\OperationUxPresenter; @@ -540,12 +541,19 @@ private static function summaryPresentation(TenantReview $record): array $summary = is_array($record->summary) ? $record->summary : []; $truthEnvelope = static::truthEnvelope($record); $reasonPresenter = app(ReasonPresenter::class); + $highlights = is_array($summary['highlights'] ?? null) ? $summary['highlights'] : []; + $findingOutcomeSummary = static::findingOutcomeSummary($summary); + $findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : []; + + if ($findingOutcomeSummary !== null) { + $highlights[] = 'Terminal outcomes: '.$findingOutcomeSummary.'.'; + } return [ 'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(), 'compressed_outcome' => static::compressedOutcome($record)->toArray(), 'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()), - 'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [], + 'highlights' => $highlights, 'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [], 'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], 'context_links' => static::summaryContextLinks($record), @@ -554,6 +562,8 @@ private static function summaryPresentation(TenantReview $record): array ['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)], ['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)], ['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)], + ['label' => 'Pending verification', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)], + ['label' => 'Verified cleared', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)], ], ]; } @@ -655,4 +665,18 @@ private static function compressedOutcome(TenantReview $record, bool $fresh = fa SurfaceCompressionContext::tenantReview(), ); } + + /** + * @param array $summary + */ + private static function findingOutcomeSummary(array $summary): ?string + { + $outcomeCounts = $summary['finding_outcomes'] ?? []; + + if (! is_array($outcomeCounts)) { + return null; + } + + return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts); + } } diff --git a/apps/platform/app/Jobs/BackfillFindingLifecycleJob.php b/apps/platform/app/Jobs/BackfillFindingLifecycleJob.php index 46be503a..ff20cc37 100644 --- a/apps/platform/app/Jobs/BackfillFindingLifecycleJob.php +++ b/apps/platform/app/Jobs/BackfillFindingLifecycleJob.php @@ -345,9 +345,12 @@ private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $bac } $finding->forceFill([ - 'status' => Finding::STATUS_RESOLVED, - 'resolved_at' => $backfillStartedAt, - 'resolved_reason' => 'consolidated_duplicate', + 'status' => Finding::STATUS_CLOSED, + 'resolved_at' => null, + 'resolved_reason' => null, + 'closed_at' => $backfillStartedAt, + 'closed_reason' => Finding::CLOSE_REASON_DUPLICATE, + 'closed_by_user_id' => null, 'recurrence_key' => null, ])->save(); diff --git a/apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php b/apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php index a4c06c35..09aae1a1 100644 --- a/apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php +++ b/apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php @@ -325,9 +325,12 @@ private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $bac } $finding->forceFill([ - 'status' => Finding::STATUS_RESOLVED, - 'resolved_at' => $backfillStartedAt, - 'resolved_reason' => 'consolidated_duplicate', + 'status' => Finding::STATUS_CLOSED, + 'resolved_at' => null, + 'resolved_reason' => null, + 'closed_at' => $backfillStartedAt, + 'closed_reason' => Finding::CLOSE_REASON_DUPLICATE, + 'closed_by_user_id' => null, 'recurrence_key' => null, ])->save(); diff --git a/apps/platform/app/Models/Finding.php b/apps/platform/app/Models/Finding.php index 955434ba..cb49b575 100644 --- a/apps/platform/app/Models/Finding.php +++ b/apps/platform/app/Models/Finding.php @@ -47,6 +47,32 @@ class Finding extends Model public const string STATUS_RISK_ACCEPTED = 'risk_accepted'; + public const string RESOLVE_REASON_REMEDIATED = 'remediated'; + + public const string RESOLVE_REASON_NO_LONGER_DRIFTING = 'no_longer_drifting'; + + public const string RESOLVE_REASON_PERMISSION_GRANTED = 'permission_granted'; + + public const string RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY = 'permission_removed_from_registry'; + + public const string RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED = 'role_assignment_removed'; + + public const string RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD = 'ga_count_within_threshold'; + + public const string CLOSE_REASON_FALSE_POSITIVE = 'false_positive'; + + public const string CLOSE_REASON_DUPLICATE = 'duplicate'; + + public const string CLOSE_REASON_NO_LONGER_APPLICABLE = 'no_longer_applicable'; + + public const string CLOSE_REASON_ACCEPTED_RISK = 'accepted_risk'; + + public const string REOPEN_REASON_RECURRED_AFTER_RESOLUTION = 'recurred_after_resolution'; + + public const string REOPEN_REASON_VERIFICATION_FAILED = 'verification_failed'; + + public const string REOPEN_REASON_MANUAL_REASSESSMENT = 'manual_reassessment'; + public const string RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY = 'orphaned_accountability'; public const string RESPONSIBILITY_STATE_OWNED_UNASSIGNED = 'owned_unassigned'; @@ -160,6 +186,113 @@ public static function highSeverityValues(): array ]; } + /** + * @return array + */ + public static function manualResolveReasonKeys(): array + { + return [ + self::RESOLVE_REASON_REMEDIATED, + ]; + } + + /** + * @return array + */ + public static function systemResolveReasonKeys(): array + { + return [ + self::RESOLVE_REASON_NO_LONGER_DRIFTING, + self::RESOLVE_REASON_PERMISSION_GRANTED, + self::RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY, + self::RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED, + self::RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD, + ]; + } + + /** + * @return array + */ + public static function resolveReasonKeys(): array + { + return [ + ...self::manualResolveReasonKeys(), + ...self::systemResolveReasonKeys(), + ]; + } + + /** + * @return array + */ + public static function closeReasonKeys(): array + { + return [ + self::CLOSE_REASON_FALSE_POSITIVE, + self::CLOSE_REASON_DUPLICATE, + self::CLOSE_REASON_NO_LONGER_APPLICABLE, + self::CLOSE_REASON_ACCEPTED_RISK, + ]; + } + + /** + * @return array + */ + public static function manualCloseReasonKeys(): array + { + return [ + self::CLOSE_REASON_FALSE_POSITIVE, + self::CLOSE_REASON_DUPLICATE, + self::CLOSE_REASON_NO_LONGER_APPLICABLE, + ]; + } + + /** + * @return array + */ + public static function reopenReasonKeys(): array + { + return [ + self::REOPEN_REASON_RECURRED_AFTER_RESOLUTION, + self::REOPEN_REASON_VERIFICATION_FAILED, + self::REOPEN_REASON_MANUAL_REASSESSMENT, + ]; + } + + public static function isResolveReason(?string $reason): bool + { + return is_string($reason) && in_array($reason, self::resolveReasonKeys(), true); + } + + public static function isManualResolveReason(?string $reason): bool + { + return is_string($reason) && in_array($reason, self::manualResolveReasonKeys(), true); + } + + public static function isSystemResolveReason(?string $reason): bool + { + return is_string($reason) && in_array($reason, self::systemResolveReasonKeys(), true); + } + + public static function isCloseReason(?string $reason): bool + { + return is_string($reason) && in_array($reason, self::closeReasonKeys(), true); + } + + public static function isManualCloseReason(?string $reason): bool + { + return is_string($reason) && in_array($reason, self::manualCloseReasonKeys(), true); + } + + public static function isRiskAcceptedReason(?string $reason): bool + { + return $reason === self::CLOSE_REASON_ACCEPTED_RISK; + } + + public static function isReopenReason(?string $reason): bool + { + return is_string($reason) && in_array($reason, self::reopenReasonKeys(), true); + } + public static function canonicalizeStatus(?string $status): ?string { if ($status === self::STATUS_ACKNOWLEDGED) { diff --git a/apps/platform/app/Services/Evidence/EvidenceSnapshotService.php b/apps/platform/app/Services/Evidence/EvidenceSnapshotService.php index b41dfd86..c4b4be40 100644 --- a/apps/platform/app/Services/Evidence/EvidenceSnapshotService.php +++ b/apps/platform/app/Services/Evidence/EvidenceSnapshotService.php @@ -213,6 +213,12 @@ public function buildSnapshotPayload(Tenant $tenant): array 'state' => $item['state'], 'required' => $item['required'], ], $items), + 'finding_outcomes' => is_array($findingsSummary['outcome_counts'] ?? null) + ? $findingsSummary['outcome_counts'] + : [], + 'finding_report_buckets' => is_array($findingsSummary['report_bucket_counts'] ?? null) + ? $findingsSummary['report_bucket_counts'] + : [], 'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : [ diff --git a/apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php b/apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php index c141392c..e70bcfc5 100644 --- a/apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php +++ b/apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php @@ -8,12 +8,14 @@ use App\Models\Tenant; use App\Services\Evidence\Contracts\EvidenceSourceProvider; use App\Services\Findings\FindingRiskGovernanceResolver; +use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Evidence\EvidenceCompletenessState; final class FindingsSummarySource implements EvidenceSourceProvider { public function __construct( private readonly FindingRiskGovernanceResolver $governanceResolver, + private readonly FindingOutcomeSemantics $findingOutcomeSemantics, ) {} public function key(): string @@ -33,6 +35,7 @@ public function collect(Tenant $tenant): array $entries = $findings->map(function (Finding $finding): array { $governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException); $governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException); + $outcome = $this->findingOutcomeSemantics->describe($finding); return [ 'id' => (int) $finding->getKey(), @@ -43,10 +46,42 @@ public function collect(Tenant $tenant): array 'description' => $finding->description, 'created_at' => $finding->created_at?->toIso8601String(), 'updated_at' => $finding->updated_at?->toIso8601String(), + 'verification_state' => $outcome['verification_state'], + 'report_bucket' => $outcome['report_bucket'], + 'terminal_outcome_key' => $outcome['terminal_outcome_key'], + 'terminal_outcome_label' => $outcome['label'], + 'terminal_outcome' => $outcome['terminal_outcome_key'] !== null ? [ + 'key' => $outcome['terminal_outcome_key'], + 'label' => $outcome['label'], + 'verification_state' => $outcome['verification_state'], + 'report_bucket' => $outcome['report_bucket'], + 'governance_state' => $governanceState, + ] : null, 'governance_state' => $governanceState, 'governance_warning' => $governanceWarning, ]; }); + $outcomeCounts = array_fill_keys($this->findingOutcomeSemantics->orderedOutcomeKeys(), 0); + $reportBucketCounts = [ + 'remediation_pending_verification' => 0, + 'remediation_verified' => 0, + 'administrative_closure' => 0, + 'accepted_risk' => 0, + ]; + + foreach ($entries as $entry) { + $terminalOutcomeKey = $entry['terminal_outcome_key'] ?? null; + $reportBucket = $entry['report_bucket'] ?? null; + + if (is_string($terminalOutcomeKey) && array_key_exists($terminalOutcomeKey, $outcomeCounts)) { + $outcomeCounts[$terminalOutcomeKey]++; + } + + if (is_string($reportBucket) && array_key_exists($reportBucket, $reportBucketCounts)) { + $reportBucketCounts[$reportBucket]++; + } + } + $riskAcceptedEntries = $entries->filter( static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED, ); @@ -78,6 +113,8 @@ public function collect(Tenant $tenant): array 'revoked_count' => $riskAcceptedEntries->where('governance_state', 'revoked_exception')->count(), 'missing_exception_count' => $riskAcceptedEntries->where('governance_state', 'risk_accepted_without_valid_exception')->count(), ], + 'outcome_counts' => $outcomeCounts, + 'report_bucket_counts' => $reportBucketCounts, 'entries' => $entries->all(), ]; diff --git a/apps/platform/app/Services/Findings/FindingExceptionService.php b/apps/platform/app/Services/Findings/FindingExceptionService.php index cace742b..936f853e 100644 --- a/apps/platform/app/Services/Findings/FindingExceptionService.php +++ b/apps/platform/app/Services/Findings/FindingExceptionService.php @@ -857,7 +857,7 @@ private function evidenceSummary(array $references): array private function findingRiskAcceptedReason(string $approvalReason): string { - return mb_substr($approvalReason, 0, 255); + return Finding::CLOSE_REASON_ACCEPTED_RISK; } private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable diff --git a/apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php b/apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php index 92332581..48742288 100644 --- a/apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php +++ b/apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php @@ -7,11 +7,16 @@ use App\Models\Finding; use App\Models\FindingException; use App\Models\FindingExceptionDecision; +use App\Support\Findings\FindingOutcomeSemantics; use Carbon\CarbonImmutable; use Illuminate\Support\Carbon; final class FindingRiskGovernanceResolver { + public function __construct( + private readonly FindingOutcomeSemantics $findingOutcomeSemantics, + ) {} + public function resolveWorkflowFamily(Finding $finding): string { return match (Finding::canonicalizeStatus((string) $finding->status)) { @@ -218,11 +223,7 @@ public function resolvePrimaryNarrative(Finding $finding, ?FindingException $exc 'accepted_risk' => $this->resolveGovernanceAttention($finding, $exception, $now) === 'healthy' ? 'Accepted risk remains visible because current governance is still valid.' : 'Accepted risk is still on record, but governance follow-up is needed before it can be treated as safe to ignore.', - 'historical' => match ((string) $finding->status) { - Finding::STATUS_RESOLVED => 'Resolved is a historical workflow state. It does not prove the issue is permanently gone.', - Finding::STATUS_CLOSED => 'Closed is a historical workflow state. It does not prove the issue is permanently gone.', - default => 'This finding is historical workflow context.', - }, + 'historical' => $this->historicalPrimaryNarrative($finding), default => match ($finding->responsibilityState()) { Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'This finding is still active workflow work and currently has orphaned accountability.', Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'This finding has an accountable owner, but the active remediation work is still unassigned.', @@ -253,8 +254,14 @@ public function resolvePrimaryNextAction(Finding $finding, ?FindingException $ex }; } - if ((string) $finding->status === Finding::STATUS_RESOLVED || (string) $finding->status === Finding::STATUS_CLOSED) { - return 'Review the closure context and reopen the finding if the issue has returned or governance has lapsed.'; + if ((string) $finding->status === Finding::STATUS_RESOLVED) { + return $this->findingOutcomeSemantics->verificationState($finding) === FindingOutcomeSemantics::VERIFICATION_PENDING + ? 'Wait for later trusted evidence to confirm the issue is actually clear, or reopen the finding if verification still fails.' + : 'Keep the finding closed unless later trusted evidence shows the issue has returned.'; + } + + if ((string) $finding->status === Finding::STATUS_CLOSED) { + return 'Review the administrative closure context and reopen the finding if the tenant reality no longer matches that decision.'; } return match ($finding->responsibilityState()) { @@ -340,23 +347,33 @@ private function renewalAwareDate(FindingException $exception, string $metadataK private function resolvedHistoricalContext(Finding $finding): ?string { - $reason = (string) ($finding->resolved_reason ?? ''); - - return match ($reason) { - 'no_longer_drifting' => 'The latest compare did not reproduce the earlier drift, but treat this as the last observed workflow outcome rather than a permanent guarantee.', - 'permission_granted', - 'permission_removed_from_registry', - 'role_assignment_removed', - 'ga_count_within_threshold' => 'The last observed workflow reason suggests the triggering condition was no longer present, but this remains historical context.', + return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) { + FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'This finding was resolved manually and is still waiting for trusted evidence to confirm the issue is actually gone.', + FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => 'Trusted evidence later confirmed the triggering condition was no longer present at the last observed check.', default => 'Resolved records the workflow outcome. Review the reason and latest evidence before treating it as technical proof.', }; } private function closedHistoricalContext(Finding $finding): ?string { - return match ((string) ($finding->closed_reason ?? '')) { - 'accepted_risk' => 'Closed reflects workflow handling. Governance validity still determines whether accepted risk remains safe to rely on.', + return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) { + FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE => 'This finding was closed as a false positive, which is an administrative closure rather than proof of remediation.', + FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE => 'This finding was closed as a duplicate, which is an administrative closure rather than proof of remediation.', + FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'This finding was closed as no longer applicable, which is an administrative closure rather than proof of remediation.', + FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED => 'Closed reflects workflow handling. Governance validity still determines whether accepted risk remains safe to rely on.', default => 'Closed records the workflow outcome. Review the recorded reason before treating it as technical proof.', }; } + + private function historicalPrimaryNarrative(Finding $finding): string + { + return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) { + FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'Resolved pending verification means an operator declared the remediation complete, but trusted verification has not confirmed it yet.', + FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => 'Verified cleared means trusted evidence later confirmed the issue was no longer present.', + FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE, + FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE, + FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'This finding is closed for an administrative reason and should not be read as a remediation outcome.', + default => 'This finding is historical workflow context.', + }; + } } diff --git a/apps/platform/app/Services/Findings/FindingWorkflowService.php b/apps/platform/app/Services/Findings/FindingWorkflowService.php index 8fe04adf..c6ac3e13 100644 --- a/apps/platform/app/Services/Findings/FindingWorkflowService.php +++ b/apps/platform/app/Services/Findings/FindingWorkflowService.php @@ -14,6 +14,7 @@ use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActorType; use App\Support\Auth\Capabilities; +use App\Support\Findings\FindingOutcomeSemantics; use Carbon\CarbonImmutable; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Support\Facades\DB; @@ -28,6 +29,7 @@ public function __construct( private readonly AuditLogger $auditLogger, private readonly CapabilityResolver $capabilityResolver, private readonly FindingNotificationService $findingNotificationService, + private readonly FindingOutcomeSemantics $findingOutcomeSemantics, ) {} /** @@ -273,7 +275,7 @@ public function resolve(Finding $finding, Tenant $tenant, User $actor, string $r throw new InvalidArgumentException('Only open findings can be resolved.'); } - $reason = $this->validatedReason($reason, 'resolved_reason'); + $reason = $this->validatedReason($reason, 'resolved_reason', Finding::manualResolveReasonKeys()); $now = CarbonImmutable::now(); return $this->mutateAndAudit( @@ -299,7 +301,7 @@ public function close(Finding $finding, Tenant $tenant, User $actor, string $rea { $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_CLOSE]); - $reason = $this->validatedReason($reason, 'closed_reason'); + $reason = $this->validatedReason($reason, 'closed_reason', Finding::manualCloseReasonKeys()); $now = CarbonImmutable::now(); return $this->mutateAndAudit( @@ -342,7 +344,7 @@ private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant throw new InvalidArgumentException('Only open findings can be marked as risk accepted.'); } - $reason = $this->validatedReason($reason, 'closed_reason'); + $reason = $this->validatedReason($reason, 'closed_reason', [Finding::CLOSE_REASON_ACCEPTED_RISK]); $now = CarbonImmutable::now(); return $this->mutateAndAudit( @@ -376,7 +378,7 @@ public function reopen(Finding $finding, Tenant $tenant, User $actor, string $re throw new InvalidArgumentException('Only terminal findings can be reopened.'); } - $reason = $this->validatedReason($reason, 'reopen_reason'); + $reason = $this->validatedReason($reason, 'reopen_reason', Finding::reopenReasonKeys()); $now = CarbonImmutable::now(); $slaDays = $this->slaPolicy->daysForFinding($finding, $tenant); $dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $now); @@ -418,11 +420,11 @@ public function resolveBySystem( ): Finding { $this->assertFindingOwnedByTenant($finding, $tenant); - if (! $finding->hasOpenStatus()) { - throw new InvalidArgumentException('Only open findings can be resolved.'); + if (! $finding->hasOpenStatus() && (string) $finding->status !== Finding::STATUS_RESOLVED) { + throw new InvalidArgumentException('Only open or manually resolved findings can be system-cleared.'); } - $reason = $this->validatedReason($reason, 'resolved_reason'); + $reason = $this->validatedReason($reason, 'resolved_reason', Finding::systemResolveReasonKeys()); return $this->mutateAndAudit( finding: $finding, @@ -456,6 +458,7 @@ public function reopenBySystem( CarbonImmutable $reopenedAt, ?int $operationRunId = null, ?callable $mutate = null, + ?string $reason = null, ): Finding { $this->assertFindingOwnedByTenant($finding, $tenant); @@ -463,6 +466,11 @@ public function reopenBySystem( throw new InvalidArgumentException('Only terminal findings can be reopened.'); } + $reason = $this->validatedReason( + $reason ?? $this->findingOutcomeSemantics->systemReopenReasonFor($finding), + 'reopen_reason', + Finding::reopenReasonKeys(), + ); $slaDays = $this->slaPolicy->daysForFinding($finding, $tenant); $dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt); @@ -474,6 +482,7 @@ public function reopenBySystem( context: [ 'metadata' => [ 'reopened_at' => $reopenedAt->toIso8601String(), + 'reopened_reason' => $reason, 'sla_days' => $slaDays, 'due_at' => $dueAt->toIso8601String(), 'system_origin' => true, @@ -574,7 +583,10 @@ private function assertTenantMemberOrNull(Tenant $tenant, ?int $userId, string $ } } - private function validatedReason(string $reason, string $field): string + /** + * @param array $allowedReasons + */ + private function validatedReason(string $reason, string $field, array $allowedReasons): string { $reason = trim($reason); @@ -586,6 +598,14 @@ private function validatedReason(string $reason, string $field): string throw new InvalidArgumentException(sprintf('%s must be at most 255 characters.', $field)); } + if (! in_array($reason, $allowedReasons, true)) { + throw new InvalidArgumentException(sprintf( + '%s must be one of: %s.', + $field, + implode(', ', $allowedReasons), + )); + } + return $reason; } @@ -637,12 +657,17 @@ private function mutateAndAudit( $record->save(); $after = $this->auditSnapshot($record); + $outcome = $this->findingOutcomeSemantics->describe($record); $auditMetadata = array_merge($metadata, [ 'finding_id' => (int) $record->getKey(), 'before_status' => $before['status'] ?? null, 'after_status' => $after['status'] ?? null, 'before' => $before, 'after' => $after, + 'terminal_outcome_key' => $outcome['terminal_outcome_key'], + 'terminal_outcome_label' => $outcome['label'], + 'verification_state' => $outcome['verification_state'], + 'report_bucket' => $outcome['report_bucket'], '_dedupe_key' => $this->dedupeKey($action, $record, $before, $after, $metadata, $actor, $actorType), ]); @@ -713,6 +738,7 @@ private function dedupeKey( 'owner_user_id' => $metadata['owner_user_id'] ?? null, 'resolved_reason' => $metadata['resolved_reason'] ?? null, 'closed_reason' => $metadata['closed_reason'] ?? null, + 'reopened_reason' => $metadata['reopened_reason'] ?? null, ]; $encoded = json_encode($payload); diff --git a/apps/platform/app/Services/ReviewPackService.php b/apps/platform/app/Services/ReviewPackService.php index 08eb352b..7278b741 100644 --- a/apps/platform/app/Services/ReviewPackService.php +++ b/apps/platform/app/Services/ReviewPackService.php @@ -83,6 +83,12 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie 'status' => ReviewPackStatus::Queued->value, 'options' => $options, 'summary' => [ + 'finding_outcomes' => is_array($snapshot->summary['finding_outcomes'] ?? null) + ? $snapshot->summary['finding_outcomes'] + : [], + 'finding_report_buckets' => is_array($snapshot->summary['finding_report_buckets'] ?? null) + ? $snapshot->summary['finding_report_buckets'] + : [], 'risk_acceptance' => is_array($snapshot->summary['risk_acceptance'] ?? null) ? $snapshot->summary['risk_acceptance'] : [], @@ -168,6 +174,12 @@ public function generateFromReview(TenantReview $review, User $user, array $opti 'review_status' => (string) $review->status, 'review_completeness_state' => (string) $review->completeness_state, 'section_count' => $review->sections->count(), + 'finding_outcomes' => is_array($review->summary['finding_outcomes'] ?? null) + ? $review->summary['finding_outcomes'] + : [], + 'finding_report_buckets' => is_array($review->summary['finding_report_buckets'] ?? null) + ? $review->summary['finding_report_buckets'] + : [], 'evidence_resolution' => [ 'outcome' => 'resolved', 'snapshot_id' => (int) $snapshot->getKey(), diff --git a/apps/platform/app/Services/TenantReviews/TenantReviewComposer.php b/apps/platform/app/Services/TenantReviews/TenantReviewComposer.php index e4693a3a..1ee97330 100644 --- a/apps/platform/app/Services/TenantReviews/TenantReviewComposer.php +++ b/apps/platform/app/Services/TenantReviews/TenantReviewComposer.php @@ -59,6 +59,12 @@ public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null 'publish_blockers' => $blockers, 'has_ready_export' => false, 'finding_count' => (int) data_get($sections, '0.summary_payload.finding_count', 0), + 'finding_outcomes' => is_array(data_get($sections, '0.summary_payload.finding_outcomes')) + ? data_get($sections, '0.summary_payload.finding_outcomes') + : [], + 'finding_report_buckets' => is_array(data_get($sections, '0.summary_payload.finding_report_buckets')) + ? data_get($sections, '0.summary_payload.finding_report_buckets') + : [], 'report_count' => 2, 'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0), 'highlights' => data_get($sections, '0.render_payload.highlights', []), diff --git a/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php b/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php index 6a9af5d0..d340e1a1 100644 --- a/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php +++ b/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php @@ -6,12 +6,17 @@ use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshotItem; +use App\Support\Findings\FindingOutcomeSemantics; use App\Support\TenantReviewCompletenessState; use Illuminate\Support\Arr; use Illuminate\Support\Collection; final class TenantReviewSectionFactory { + public function __construct( + private readonly FindingOutcomeSemantics $findingOutcomeSemantics, + ) {} + /** * @return list> */ @@ -47,6 +52,8 @@ private function executiveSummarySection( $rolesSummary = $this->summary($rolesItem); $baselineSummary = $this->summary($baselineItem); $operationsSummary = $this->summary($operationsItem); + $findingOutcomes = is_array($findingsSummary['outcome_counts'] ?? null) ? $findingsSummary['outcome_counts'] : []; + $findingReportBuckets = is_array($findingsSummary['report_bucket_counts'] ?? null) ? $findingsSummary['report_bucket_counts'] : []; $riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : []; $openCount = (int) ($findingsSummary['open_count'] ?? 0); @@ -55,9 +62,11 @@ private function executiveSummarySection( $postureScore = $permissionSummary['posture_score'] ?? null; $operationFailures = (int) ($operationsSummary['failed_count'] ?? 0); $partialOperations = (int) ($operationsSummary['partial_count'] ?? 0); + $outcomeSummary = $this->findingOutcomeSemantics->compactOutcomeSummary($findingOutcomes); $highlights = array_values(array_filter([ sprintf('%d open risks from %d tracked findings.', $openCount, $findingCount), + $outcomeSummary !== null ? 'Terminal outcomes: '.$outcomeSummary.'.' : null, $postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.', sprintf('%d baseline drift findings remain open.', $driftCount), sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations), @@ -81,6 +90,8 @@ private function executiveSummarySection( 'summary_payload' => [ 'finding_count' => $findingCount, 'open_risk_count' => $openCount, + 'finding_outcomes' => $findingOutcomes, + 'finding_report_buckets' => $findingReportBuckets, 'posture_score' => $postureScore, 'baseline_drift_count' => $driftCount, 'failed_operation_count' => $operationFailures, diff --git a/apps/platform/app/Support/Filament/FilterOptionCatalog.php b/apps/platform/app/Support/Filament/FilterOptionCatalog.php index 8db3ecb2..81b03d6c 100644 --- a/apps/platform/app/Support/Filament/FilterOptionCatalog.php +++ b/apps/platform/app/Support/Filament/FilterOptionCatalog.php @@ -13,6 +13,7 @@ use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeDomain; use App\Support\Baselines\BaselineProfileStatus; +use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\OperationCatalog; use App\Support\RestoreRunStatus; @@ -142,6 +143,22 @@ public static function findingWorkflowFamilies(): array ]; } + /** + * @return array + */ + public static function findingTerminalOutcomes(): array + { + return app(FindingOutcomeSemantics::class)->terminalOutcomeOptions(); + } + + /** + * @return array + */ + public static function findingVerificationStates(): array + { + return app(FindingOutcomeSemantics::class)->verificationStateOptions(); + } + /** * @return array */ diff --git a/apps/platform/app/Support/Findings/FindingOutcomeSemantics.php b/apps/platform/app/Support/Findings/FindingOutcomeSemantics.php new file mode 100644 index 00000000..74e391c7 --- /dev/null +++ b/apps/platform/app/Support/Findings/FindingOutcomeSemantics.php @@ -0,0 +1,203 @@ +terminalOutcomeKey($finding); + $verificationState = $this->verificationState($finding); + + return [ + 'terminal_outcome_key' => $terminalOutcomeKey, + 'label' => $terminalOutcomeKey !== null ? $this->terminalOutcomeLabel($terminalOutcomeKey) : null, + 'verification_state' => $verificationState, + 'verification_label' => $verificationState !== self::VERIFICATION_NOT_APPLICABLE + ? $this->verificationStateLabel($verificationState) + : null, + 'report_bucket' => $terminalOutcomeKey !== null ? $this->reportBucket($terminalOutcomeKey) : null, + ]; + } + + public function terminalOutcomeKey(Finding $finding): ?string + { + return match ((string) $finding->status) { + Finding::STATUS_RESOLVED => $this->resolvedTerminalOutcomeKey((string) ($finding->resolved_reason ?? '')), + Finding::STATUS_CLOSED => $this->closedTerminalOutcomeKey((string) ($finding->closed_reason ?? '')), + Finding::STATUS_RISK_ACCEPTED => self::OUTCOME_RISK_ACCEPTED, + default => null, + }; + } + + public function verificationState(Finding $finding): string + { + if ((string) $finding->status !== Finding::STATUS_RESOLVED) { + return self::VERIFICATION_NOT_APPLICABLE; + } + + $reason = (string) ($finding->resolved_reason ?? ''); + + if (Finding::isSystemResolveReason($reason)) { + return self::VERIFICATION_VERIFIED; + } + + if (Finding::isManualResolveReason($reason)) { + return self::VERIFICATION_PENDING; + } + + return self::VERIFICATION_NOT_APPLICABLE; + } + + public function systemReopenReasonFor(Finding $finding): string + { + return $this->verificationState($finding) === self::VERIFICATION_PENDING + ? Finding::REOPEN_REASON_VERIFICATION_FAILED + : Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION; + } + + /** + * @return array + */ + public function terminalOutcomeOptions(): array + { + return [ + self::OUTCOME_RESOLVED_PENDING_VERIFICATION => $this->terminalOutcomeLabel(self::OUTCOME_RESOLVED_PENDING_VERIFICATION), + self::OUTCOME_VERIFIED_CLEARED => $this->terminalOutcomeLabel(self::OUTCOME_VERIFIED_CLEARED), + self::OUTCOME_CLOSED_FALSE_POSITIVE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_FALSE_POSITIVE), + self::OUTCOME_CLOSED_DUPLICATE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_DUPLICATE), + self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE), + self::OUTCOME_RISK_ACCEPTED => $this->terminalOutcomeLabel(self::OUTCOME_RISK_ACCEPTED), + ]; + } + + /** + * @return array + */ + public function verificationStateOptions(): array + { + return [ + self::VERIFICATION_PENDING => $this->verificationStateLabel(self::VERIFICATION_PENDING), + self::VERIFICATION_VERIFIED => $this->verificationStateLabel(self::VERIFICATION_VERIFIED), + self::VERIFICATION_NOT_APPLICABLE => $this->verificationStateLabel(self::VERIFICATION_NOT_APPLICABLE), + ]; + } + + public function terminalOutcomeLabel(string $terminalOutcomeKey): string + { + return match ($terminalOutcomeKey) { + self::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'Resolved pending verification', + self::OUTCOME_VERIFIED_CLEARED => 'Verified cleared', + self::OUTCOME_CLOSED_FALSE_POSITIVE => 'Closed as false positive', + self::OUTCOME_CLOSED_DUPLICATE => 'Closed as duplicate', + self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'Closed as no longer applicable', + self::OUTCOME_RISK_ACCEPTED => 'Risk accepted', + default => 'Unknown outcome', + }; + } + + public function verificationStateLabel(string $verificationState): string + { + return match ($verificationState) { + self::VERIFICATION_PENDING => 'Pending verification', + self::VERIFICATION_VERIFIED => 'Verified cleared', + default => 'Not applicable', + }; + } + + public function reportBucket(string $terminalOutcomeKey): string + { + return match ($terminalOutcomeKey) { + self::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'remediation_pending_verification', + self::OUTCOME_VERIFIED_CLEARED => 'remediation_verified', + self::OUTCOME_RISK_ACCEPTED => 'accepted_risk', + default => 'administrative_closure', + }; + } + + public function compactOutcomeSummary(array $counts): ?string + { + $parts = []; + + foreach ($this->orderedOutcomeKeys() as $outcomeKey) { + $count = (int) ($counts[$outcomeKey] ?? 0); + + if ($count < 1) { + continue; + } + + $parts[] = sprintf('%d %s', $count, strtolower($this->terminalOutcomeLabel($outcomeKey))); + } + + return $parts === [] ? null : implode(', ', $parts); + } + + /** + * @return array + */ + public function orderedOutcomeKeys(): array + { + return [ + self::OUTCOME_RESOLVED_PENDING_VERIFICATION, + self::OUTCOME_VERIFIED_CLEARED, + self::OUTCOME_CLOSED_FALSE_POSITIVE, + self::OUTCOME_CLOSED_DUPLICATE, + self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE, + self::OUTCOME_RISK_ACCEPTED, + ]; + } + + private function resolvedTerminalOutcomeKey(string $reason): ?string + { + if (Finding::isSystemResolveReason($reason)) { + return self::OUTCOME_VERIFIED_CLEARED; + } + + if (Finding::isManualResolveReason($reason)) { + return self::OUTCOME_RESOLVED_PENDING_VERIFICATION; + } + + return null; + } + + private function closedTerminalOutcomeKey(string $reason): ?string + { + return match ($reason) { + Finding::CLOSE_REASON_FALSE_POSITIVE => self::OUTCOME_CLOSED_FALSE_POSITIVE, + Finding::CLOSE_REASON_DUPLICATE => self::OUTCOME_CLOSED_DUPLICATE, + Finding::CLOSE_REASON_NO_LONGER_APPLICABLE => self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE, + default => null, + }; + } +} diff --git a/apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php b/apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php index 178adf98..4d42629f 100644 --- a/apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php +++ b/apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php @@ -70,7 +70,7 @@ public static function families(): array 'canonicalObject' => 'finding', 'panels' => ['tenant'], 'surfaceKeys' => ['view_finding', 'finding_list_row', 'finding_bulk'], - 'defaultActionOrder' => ['close_finding', 'reopen_finding'], + 'defaultActionOrder' => ['resolve_finding', 'close_finding', 'reopen_finding'], 'supportsDocumentedDeviation' => false, 'defaultMutationScopeSource' => 'finding lifecycle', ], @@ -260,6 +260,20 @@ public static function rules(): array serviceOwner: 'OperationRunTriageService', surfaceKeys: ['system_view_run'], ), + 'resolve_finding' => new GovernanceActionRule( + actionKey: 'resolve_finding', + familyKey: 'finding_lifecycle', + frictionClass: GovernanceFrictionClass::F2, + reasonPolicy: GovernanceReasonPolicy::Required, + dangerPolicy: 'none', + canonicalLabel: 'Resolve', + modalHeading: 'Resolve finding', + modalDescription: 'Resolve this finding for the current tenant. TenantPilot records a canonical remediation outcome and keeps the finding in a pending-verification state until trusted evidence later confirms it is actually clear.', + successTitle: 'Finding resolved pending verification', + auditVerb: 'resolve finding', + serviceOwner: 'FindingWorkflowService', + surfaceKeys: ['view_finding', 'finding_list_row', 'finding_bulk'], + ), 'close_finding' => new GovernanceActionRule( actionKey: 'close_finding', familyKey: 'finding_lifecycle', @@ -268,7 +282,7 @@ public static function rules(): array dangerPolicy: 'none', canonicalLabel: 'Close', modalHeading: 'Close finding', - modalDescription: 'Close this finding for the current tenant. TenantPilot records the closing rationale and closes the finding lifecycle.', + modalDescription: 'Close this finding for the current tenant. TenantPilot records a canonical administrative closure reason such as false positive, duplicate, or no longer applicable.', successTitle: 'Finding closed', auditVerb: 'close finding', serviceOwner: 'FindingWorkflowService', @@ -282,7 +296,7 @@ public static function rules(): array dangerPolicy: 'none', canonicalLabel: 'Reopen', modalHeading: 'Reopen finding', - modalDescription: 'Reopen this closed finding for the current tenant. TenantPilot records why the lifecycle is being reopened and recalculates due attention.', + modalDescription: 'Reopen this terminal finding for the current tenant. TenantPilot records a canonical reopen reason and recalculates due attention.', successTitle: 'Finding reopened', auditVerb: 'reopen finding', serviceOwner: 'FindingWorkflowService', @@ -489,6 +503,17 @@ public static function surfaceBindings(): array 'uiFieldKey' => 'reason', 'auditChannel' => 'system_audit', ], + [ + 'surfaceKey' => 'view_finding', + 'pageClass' => 'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding', + 'actionName' => 'resolve', + 'familyKey' => 'finding_lifecycle', + 'statePredicate' => 'finding has open status', + 'primaryOrSecondary' => 'primary', + 'capabilityKey' => 'tenant_findings.resolve', + 'uiFieldKey' => 'resolved_reason', + 'auditChannel' => 'tenant_audit', + ], [ 'surfaceKey' => 'view_finding', 'pageClass' => 'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding', diff --git a/apps/platform/database/factories/FindingFactory.php b/apps/platform/database/factories/FindingFactory.php index 6f46d6fc..1c794878 100644 --- a/apps/platform/database/factories/FindingFactory.php +++ b/apps/platform/database/factories/FindingFactory.php @@ -120,7 +120,16 @@ public function resolved(): static return $this->state(fn (array $attributes): array => [ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => now(), - 'resolved_reason' => 'permission_granted', + 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED, + ]); + } + + public function verifiedCleared(string $reason = Finding::RESOLVE_REASON_NO_LONGER_DRIFTING): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_at' => now(), + 'resolved_reason' => $reason, ]); } @@ -176,7 +185,7 @@ public function closed(): static return $this->state(fn (array $attributes): array => [ 'status' => Finding::STATUS_CLOSED, 'closed_at' => now(), - 'closed_reason' => 'duplicate', + 'closed_reason' => Finding::CLOSE_REASON_DUPLICATE, ]); } @@ -188,7 +197,7 @@ public function riskAccepted(): static return $this->state(fn (array $attributes): array => [ 'status' => Finding::STATUS_RISK_ACCEPTED, 'closed_at' => now(), - 'closed_reason' => 'accepted_risk', + 'closed_reason' => Finding::CLOSE_REASON_ACCEPTED_RISK, ]); } diff --git a/apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php b/apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php index 8ecb1429..7a7b5016 100644 --- a/apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php +++ b/apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php @@ -1100,7 +1100,7 @@ $finding->forceFill([ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => now()->subMinute(), - 'resolved_reason' => 'manually_resolved', + 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED, ])->save(); $firstRun->update(['completed_at' => now()->subMinute()]); diff --git a/apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php b/apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php index 4762f130..1772a7f4 100644 --- a/apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php +++ b/apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php @@ -63,3 +63,52 @@ ->assertSee('Baseline compare') ->assertSee('Operation #'.$run->getKey()); }); + +it('shows canonical manual terminal outcome and verification labels on finding detail', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $finding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED, + 'resolved_at' => now()->subHour(), + ]); + + $this->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant)) + ->assertOk() + ->assertSee('Terminal outcome') + ->assertSee('Resolved pending verification') + ->assertSee('Verification') + ->assertSee('Pending verification') + ->assertSee('Resolved reason') + ->assertSee('Remediated'); +}); + +it('shows verified clear and administrative closure labels on finding detail', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $verifiedFinding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING, + 'resolved_at' => now()->subHour(), + ]); + + $closedFinding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_CLOSED, + 'closed_reason' => Finding::CLOSE_REASON_DUPLICATE, + 'closed_at' => now()->subHour(), + ]); + + $this->get(FindingResource::getUrl('view', ['record' => $verifiedFinding], tenant: $tenant)) + ->assertOk() + ->assertSee('Verified cleared') + ->assertSee('No longer drifting'); + + $this->get(FindingResource::getUrl('view', ['record' => $closedFinding], tenant: $tenant)) + ->assertOk() + ->assertSee('Closed as duplicate') + ->assertSee('Duplicate'); +}); diff --git a/apps/platform/tests/Feature/Findings/FindingAuditBackstopTest.php b/apps/platform/tests/Feature/Findings/FindingAuditBackstopTest.php index 851e1bd2..e37840a1 100644 --- a/apps/platform/tests/Feature/Findings/FindingAuditBackstopTest.php +++ b/apps/platform/tests/Feature/Findings/FindingAuditBackstopTest.php @@ -20,9 +20,9 @@ $service->triage($finding, $tenant, $user); $service->assign($finding->refresh(), $tenant, $user, null, (int) $user->getKey()); - $service->resolve($finding->refresh(), $tenant, $user, 'patched'); - $service->reopen($finding->refresh(), $tenant, $user, 'The issue recurred after validation.'); - $service->close($finding->refresh(), $tenant, $user, 'duplicate'); + $service->resolve($finding->refresh(), $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED); + $service->reopen($finding->refresh(), $tenant, $user, Finding::REOPEN_REASON_MANUAL_REASSESSMENT); + $service->close($finding->refresh(), $tenant, $user, Finding::CLOSE_REASON_DUPLICATE); expect(AuditLog::query() ->where('tenant_id', (int) $tenant->getKey()) @@ -37,14 +37,14 @@ ->and($closedAudit->targetDisplayLabel())->toContain('finding') ->and(data_get($closedAudit->metadata, 'before_status'))->toBe(Finding::STATUS_REOPENED) ->and(data_get($closedAudit->metadata, 'after_status'))->toBe(Finding::STATUS_CLOSED) - ->and(data_get($closedAudit->metadata, 'closed_reason'))->toBe('duplicate') + ->and(data_get($closedAudit->metadata, 'closed_reason'))->toBe(Finding::CLOSE_REASON_DUPLICATE) ->and(data_get($closedAudit->metadata, 'before.evidence_jsonb'))->toBeNull() ->and(data_get($closedAudit->metadata, 'after.evidence_jsonb'))->toBeNull(); $reopenedAudit = $this->latestFindingAudit($finding, AuditActionId::FindingReopened); expect($reopenedAudit)->not->toBeNull() - ->and(data_get($reopenedAudit->metadata, 'reopened_reason'))->toBe('The issue recurred after validation.'); + ->and(data_get($reopenedAudit->metadata, 'reopened_reason'))->toBe(Finding::REOPEN_REASON_MANUAL_REASSESSMENT); }); it('deduplicates repeated finding audit writes for the same successful mutation payload', function (): void { diff --git a/apps/platform/tests/Feature/Findings/FindingAuditLogTest.php b/apps/platform/tests/Feature/Findings/FindingAuditLogTest.php index c3440b3a..5c0f68bb 100644 --- a/apps/platform/tests/Feature/Findings/FindingAuditLogTest.php +++ b/apps/platform/tests/Feature/Findings/FindingAuditLogTest.php @@ -6,6 +6,7 @@ use App\Models\Finding; use App\Models\User; use App\Services\Findings\FindingWorkflowService; +use App\Support\Findings\FindingOutcomeSemantics; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -24,7 +25,7 @@ $service = app(FindingWorkflowService::class); $service->triage($finding, $tenant, $user); - $service->resolve($finding->refresh(), $tenant, $user, 'fixed'); + $service->resolve($finding->refresh(), $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED); $audit = AuditLog::query() ->where('tenant_id', (int) $tenant->getKey()) @@ -40,7 +41,9 @@ ->and(data_get($audit->metadata, 'finding_id'))->toBe((int) $finding->getKey()) ->and(data_get($audit->metadata, 'before_status'))->toBe(Finding::STATUS_TRIAGED) ->and(data_get($audit->metadata, 'after_status'))->toBe(Finding::STATUS_RESOLVED) - ->and(data_get($audit->metadata, 'resolved_reason'))->toBe('fixed') + ->and(data_get($audit->metadata, 'resolved_reason'))->toBe(Finding::RESOLVE_REASON_REMEDIATED) + ->and(data_get($audit->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION) + ->and(data_get($audit->metadata, 'verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_PENDING) ->and(data_get($audit->metadata, 'before'))->toBeArray() ->and(data_get($audit->metadata, 'after'))->toBeArray() ->and(data_get($audit->metadata, 'evidence_jsonb'))->toBeNull() diff --git a/apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php b/apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php index 84e72f56..d7278ae8 100644 --- a/apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php +++ b/apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php @@ -120,7 +120,7 @@ function invokeAutomationBaselineCompareUpsertFindings( expect($audit)->not->toBeNull() ->and($audit->actorSnapshot()->type)->toBe(AuditActorType::System) ->and(data_get($audit->metadata, 'system_origin'))->toBeTrue() - ->and(data_get($audit->metadata, 'resolved_reason'))->toBe('no_longer_drifting'); + ->and(data_get($audit->metadata, 'resolved_reason'))->toBe(Finding::RESOLVE_REASON_NO_LONGER_DRIFTING); }); it('writes system-origin audit rows for permission posture auto-resolve and recurrence reopen', function (): void { @@ -185,7 +185,7 @@ function invokeAutomationBaselineCompareUpsertFindings( expect($resolvedAudit)->not->toBeNull() ->and($resolvedAudit->actorSnapshot()->type)->toBe(AuditActorType::System) - ->and(data_get($resolvedAudit->metadata, 'resolved_reason'))->toBe('permission_granted') + ->and(data_get($resolvedAudit->metadata, 'resolved_reason'))->toBe(Finding::RESOLVE_REASON_PERMISSION_GRANTED) ->and($reopenedAudit)->not->toBeNull() ->and($reopenedAudit->actorSnapshot()->type)->toBe(AuditActorType::System) ->and(data_get($reopenedAudit->metadata, 'after_status'))->toBe(Finding::STATUS_REOPENED); @@ -299,7 +299,7 @@ function invokeAutomationBaselineCompareUpsertFindings( $finding->forceFill([ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => CarbonImmutable::parse('2026-03-18T09:00:00Z'), - 'resolved_reason' => 'fixed', + 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED, ])->save(); $run2 = OperationRun::factory()->create([ diff --git a/apps/platform/tests/Feature/Findings/FindingBackfillTest.php b/apps/platform/tests/Feature/Findings/FindingBackfillTest.php index 9517519c..24b28f0f 100644 --- a/apps/platform/tests/Feature/Findings/FindingBackfillTest.php +++ b/apps/platform/tests/Feature/Findings/FindingBackfillTest.php @@ -91,7 +91,7 @@ 'subject_external_id' => 'policy-dupe', 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'), - 'resolved_reason' => 'fixed', + 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED, 'recurrence_key' => null, 'evidence_jsonb' => $evidence, 'first_seen_at' => null, @@ -126,9 +126,11 @@ ->and($open->status)->toBe(Finding::STATUS_NEW); expect($duplicate->recurrence_key)->toBeNull() - ->and($duplicate->status)->toBe(Finding::STATUS_RESOLVED) - ->and($duplicate->resolved_reason)->toBe('consolidated_duplicate') - ->and($duplicate->resolved_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00'); + ->and($duplicate->status)->toBe(Finding::STATUS_CLOSED) + ->and($duplicate->resolved_reason)->toBeNull() + ->and($duplicate->resolved_at)->toBeNull() + ->and($duplicate->closed_reason)->toBe(Finding::CLOSE_REASON_DUPLICATE) + ->and($duplicate->closed_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00'); CarbonImmutable::setTestNow(); }); diff --git a/apps/platform/tests/Feature/Findings/FindingBulkActionsTest.php b/apps/platform/tests/Feature/Findings/FindingBulkActionsTest.php index 9a49b484..4a9426de 100644 --- a/apps/platform/tests/Feature/Findings/FindingBulkActionsTest.php +++ b/apps/platform/tests/Feature/Findings/FindingBulkActionsTest.php @@ -88,7 +88,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array $component ->callTableBulkAction('resolve_selected', $resolveFindings, data: [ - 'resolved_reason' => 'fixed', + 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED, ]) ->assertHasNoTableBulkActionErrors(); @@ -96,7 +96,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array $finding->refresh(); expect($finding->status)->toBe(Finding::STATUS_RESOLVED) - ->and($finding->resolved_reason)->toBe('fixed') + ->and($finding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED) ->and($finding->resolved_at)->not->toBeNull(); }); @@ -114,7 +114,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array $component ->callTableBulkAction('close_selected', $closeFindings, data: [ - 'closed_reason' => 'not applicable', + 'closed_reason' => Finding::CLOSE_REASON_NO_LONGER_APPLICABLE, ]) ->assertHasNoTableBulkActionErrors(); @@ -122,7 +122,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array $finding->refresh(); expect($finding->status)->toBe(Finding::STATUS_CLOSED) - ->and($finding->closed_reason)->toBe('not applicable') + ->and($finding->closed_reason)->toBe(Finding::CLOSE_REASON_NO_LONGER_APPLICABLE) ->and($finding->closed_at)->not->toBeNull() ->and($finding->closed_by_user_id)->not->toBeNull(); }); diff --git a/apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php b/apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php new file mode 100644 index 00000000..c3914e80 --- /dev/null +++ b/apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php @@ -0,0 +1,145 @@ + Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED, + ]), + 'verified_cleared' => Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING, + ]), + 'closed_duplicate' => Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_CLOSED, + 'closed_reason' => Finding::CLOSE_REASON_DUPLICATE, + ]), + 'risk_accepted' => Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_RISK_ACCEPTED, + 'closed_reason' => Finding::CLOSE_REASON_ACCEPTED_RISK, + ]), + ]; +} + +function materializeFindingOutcomeSnapshot(\App\Models\Tenant $tenant): EvidenceSnapshot +{ + $payload = app(EvidenceSnapshotService::class)->buildSnapshotPayload($tenant); + + $snapshot = EvidenceSnapshot::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => EvidenceSnapshotStatus::Active->value, + 'fingerprint' => $payload['fingerprint'], + 'completeness_state' => $payload['completeness'], + 'summary' => $payload['summary'], + 'generated_at' => now(), + ]); + + foreach ($payload['items'] as $item) { + $snapshot->items()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'dimension_key' => $item['dimension_key'], + 'state' => $item['state'], + 'required' => $item['required'], + 'source_kind' => $item['source_kind'], + 'source_record_type' => $item['source_record_type'], + 'source_record_id' => $item['source_record_id'], + 'source_fingerprint' => $item['source_fingerprint'], + 'measured_at' => $item['measured_at'], + 'freshness_at' => $item['freshness_at'], + 'summary_payload' => $item['summary_payload'], + 'sort_order' => $item['sort_order'], + ]); + } + + return $snapshot->load('items'); +} + +it('summarizes canonical terminal outcomes and report buckets from findings evidence', function (): void { + [, $tenant] = createUserWithTenant(role: 'owner'); + + $findings = seedFindingOutcomeMatrix($tenant); + + $summary = app(FindingsSummarySource::class)->collect($tenant)['summary_payload'] ?? []; + + expect(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION))->toBe(1) + ->and(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED))->toBe(1) + ->and(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE))->toBe(1) + ->and(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED))->toBe(1) + ->and(data_get($summary, 'report_bucket_counts.remediation_pending_verification'))->toBe(1) + ->and(data_get($summary, 'report_bucket_counts.remediation_verified'))->toBe(1) + ->and(data_get($summary, 'report_bucket_counts.administrative_closure'))->toBe(1) + ->and(data_get($summary, 'report_bucket_counts.accepted_risk'))->toBe(1); + + $pendingEntry = collect($summary['entries'] ?? [])->firstWhere('id', (int) $findings['pending_verification']->getKey()); + $verifiedEntry = collect($summary['entries'] ?? [])->firstWhere('id', (int) $findings['verified_cleared']->getKey()); + + expect(data_get($pendingEntry, 'terminal_outcome.key'))->toBe(FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION) + ->and(data_get($pendingEntry, 'terminal_outcome.verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_PENDING) + ->and(data_get($verifiedEntry, 'terminal_outcome.key'))->toBe(FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED) + ->and(data_get($verifiedEntry, 'terminal_outcome.verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_VERIFIED); +}); + +it('propagates finding outcome summaries into evidence snapshots tenant reviews and review packs', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + seedFindingOutcomeMatrix($tenant); + + $snapshot = materializeFindingOutcomeSnapshot($tenant); + + expect(data_get($snapshot->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION))->toBe(1) + ->and(data_get($snapshot->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED))->toBe(1) + ->and(data_get($snapshot->summary, 'finding_report_buckets.accepted_risk'))->toBe(1); + + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + + expect(data_get($review->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE))->toBe(1) + ->and(data_get($review->summary, 'finding_report_buckets.administrative_closure'))->toBe(1); + + setTenantPanelContext($tenant); + + $this->actingAs($user) + ->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)) + ->assertOk() + ->assertSee('Terminal outcomes:') + ->assertSee('resolved pending verification') + ->assertSee('verified cleared') + ->assertSee('closed as duplicate') + ->assertSee('risk accepted'); + + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + Livewire::actingAs($user) + ->test(ReviewRegister::class) + ->assertCanSeeTableRecords([$review]) + ->assertSee('Terminal outcomes:') + ->assertSee('resolved pending verification'); + + $pack = app(ReviewPackService::class)->generateFromReview($review, $user, [ + 'include_pii' => false, + 'include_operations' => false, + ]); + + expect(data_get($pack->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED))->toBe(1) + ->and(data_get($pack->summary, 'finding_report_buckets.accepted_risk'))->toBe(1); +}); diff --git a/apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php b/apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php index 0f02aec3..3d50f2e6 100644 --- a/apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php +++ b/apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php @@ -8,6 +8,7 @@ use App\Models\OperationRun; use App\Models\Tenant; use App\Services\Findings\FindingSlaPolicy; +use App\Support\Audit\AuditActionId; use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -213,7 +214,7 @@ function baselineCompareRbacDriftItem( $finding->forceFill([ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'), - 'resolved_reason' => 'fixed', + 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED, ])->save(); $observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z'); @@ -259,6 +260,11 @@ function baselineCompareRbacDriftItem( ->and($finding->sla_days)->toBe($expectedSlaDays2) ->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt2->toIso8601String()) ->and((int) $finding->current_operation_run_id)->toBe((int) $run2->getKey()); + + $reopenedAudit = $this->latestFindingAudit($finding, AuditActionId::FindingReopened); + + expect(data_get($reopenedAudit?->metadata, 'reopened_reason')) + ->toBe(Finding::REOPEN_REASON_VERIFICATION_FAILED); }); it('keeps closed baseline compare drift findings terminal on recurrence but updates seen tracking', function (): void { @@ -308,7 +314,7 @@ function baselineCompareRbacDriftItem( $finding->forceFill([ 'status' => Finding::STATUS_CLOSED, 'closed_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'), - 'closed_reason' => 'accepted', + 'closed_reason' => Finding::CLOSE_REASON_DUPLICATE, ])->save(); $observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z'); @@ -396,7 +402,7 @@ function baselineCompareRbacDriftItem( $finding->forceFill([ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => CarbonImmutable::parse('2026-02-26T00:00:00Z'), - 'resolved_reason' => 'manual', + 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED, ])->save(); $observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z'); @@ -485,7 +491,7 @@ function baselineCompareRbacDriftItem( $finding->forceFill([ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'), - 'resolved_reason' => 'fixed', + 'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING, ])->save(); $observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z'); @@ -523,4 +529,9 @@ function baselineCompareRbacDriftItem( ->and($finding->status)->toBe(Finding::STATUS_REOPENED) ->and($finding->times_seen)->toBe(2) ->and((string) data_get($finding->evidence_jsonb, 'rbac_role_definition.diff_fingerprint'))->toBe('rbac-diff-b'); + + $reopenedAudit = $this->latestFindingAudit($finding, AuditActionId::FindingReopened); + + expect(data_get($reopenedAudit?->metadata, 'reopened_reason')) + ->toBe(Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION); }); diff --git a/apps/platform/tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php b/apps/platform/tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php index 1afca02e..b4b3dc81 100644 --- a/apps/platform/tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php +++ b/apps/platform/tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php @@ -7,8 +7,10 @@ use App\Models\Finding; use App\Models\FindingException; use App\Models\User; +use App\Services\Evidence\Sources\FindingsSummarySource; use App\Services\Findings\FindingExceptionService; use App\Services\Findings\FindingRiskGovernanceResolver; +use App\Support\Findings\FindingOutcomeSemantics; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -171,3 +173,24 @@ expect(app(FindingRiskGovernanceResolver::class)->resolveFindingState($finding->fresh('findingException'))) ->toBe('ungoverned'); }); + +it('keeps accepted risk in a separate reporting bucket from administrative closures', function (): void { + [, $tenant] = createUserWithTenant(role: 'owner'); + + Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_RISK_ACCEPTED, + 'closed_reason' => Finding::CLOSE_REASON_ACCEPTED_RISK, + ]); + + Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_CLOSED, + 'closed_reason' => Finding::CLOSE_REASON_DUPLICATE, + ]); + + $summary = app(FindingsSummarySource::class)->collect($tenant)['summary_payload'] ?? []; + + expect(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED))->toBe(1) + ->and(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE))->toBe(1) + ->and(data_get($summary, 'report_bucket_counts.accepted_risk'))->toBe(1) + ->and(data_get($summary, 'report_bucket_counts.administrative_closure'))->toBe(1); +}); diff --git a/apps/platform/tests/Feature/Findings/FindingWorkflowConcurrencyTest.php b/apps/platform/tests/Feature/Findings/FindingWorkflowConcurrencyTest.php index 1759fa17..c8cc2691 100644 --- a/apps/platform/tests/Feature/Findings/FindingWorkflowConcurrencyTest.php +++ b/apps/platform/tests/Feature/Findings/FindingWorkflowConcurrencyTest.php @@ -100,7 +100,7 @@ function invokeConcurrencyBaselineCompareUpsertFindings( ->firstOrFail(); CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-03-18T10:00:00Z')); - app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, 'human-won'); + app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED); $run2 = OperationRun::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), @@ -156,7 +156,7 @@ function invokeConcurrencyBaselineCompareUpsertFindings( ->firstOrFail(); CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-03-18T09:00:00Z')); - app(FindingWorkflowService::class)->close($finding, $tenant, $user, 'accepted'); + app(FindingWorkflowService::class)->close($finding, $tenant, $user, Finding::CLOSE_REASON_DUPLICATE); $run2 = OperationRun::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), diff --git a/apps/platform/tests/Feature/Findings/FindingWorkflowRowActionsTest.php b/apps/platform/tests/Feature/Findings/FindingWorkflowRowActionsTest.php index 0ebebf11..e353f0b8 100644 --- a/apps/platform/tests/Feature/Findings/FindingWorkflowRowActionsTest.php +++ b/apps/platform/tests/Feature/Findings/FindingWorkflowRowActionsTest.php @@ -47,19 +47,19 @@ $component ->callTableAction('resolve', $finding, [ - 'resolved_reason' => 'patched', + 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED, ]) ->assertHasNoTableActionErrors(); $finding->refresh(); expect($finding->status)->toBe(Finding::STATUS_RESOLVED) - ->and($finding->resolved_reason)->toBe('patched') + ->and($finding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED) ->and($finding->resolved_at)->not->toBeNull(); $component ->filterTable('open', false) ->callTableAction('reopen', $finding, [ - 'reopen_reason' => 'The issue recurred in a later scan.', + 'reopen_reason' => Finding::REOPEN_REASON_MANUAL_REASSESSMENT, ]) ->assertHasNoTableActionErrors(); @@ -86,7 +86,7 @@ $component ->callTableAction('close', $closeFinding, [ - 'closed_reason' => 'duplicate ticket', + 'closed_reason' => Finding::CLOSE_REASON_DUPLICATE, ]) ->assertHasNoTableActionErrors(); @@ -100,7 +100,7 @@ ->assertHasNoTableActionErrors(); expect($closeFinding->refresh()->status)->toBe(Finding::STATUS_CLOSED) - ->and($closeFinding->closed_reason)->toBe('duplicate ticket'); + ->and($closeFinding->closed_reason)->toBe(Finding::CLOSE_REASON_DUPLICATE); $exception = FindingException::query() ->where('finding_id', (int) $exceptionFinding->getKey()) diff --git a/apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php b/apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php index ff28482e..6b8dd275 100644 --- a/apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php +++ b/apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php @@ -6,9 +6,15 @@ use App\Models\User; use App\Services\Findings\FindingWorkflowService; use App\Support\Audit\AuditActionId; +use App\Support\Findings\FindingOutcomeSemantics; +use Carbon\CarbonImmutable; use Illuminate\Auth\Access\AuthorizationException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +afterEach(function (): void { + CarbonImmutable::setTestNow(); +}); + it('enforces the canonical transition matrix for service-driven status changes', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -25,17 +31,24 @@ expect($inProgressFinding->status)->toBe(Finding::STATUS_IN_PROGRESS) ->and($this->latestFindingAudit($inProgressFinding, AuditActionId::FindingInProgress))->not->toBeNull(); - $resolvedFinding = $service->resolve($inProgressFinding, $tenant, $user, 'patched'); + $resolvedFinding = $service->resolve($inProgressFinding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED); + $resolvedAudit = $this->latestFindingAudit($resolvedFinding, AuditActionId::FindingResolved); expect($resolvedFinding->status)->toBe(Finding::STATUS_RESOLVED) - ->and($resolvedFinding->resolved_reason)->toBe('patched') - ->and($this->latestFindingAudit($resolvedFinding, AuditActionId::FindingResolved))->not->toBeNull(); + ->and($resolvedFinding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED) + ->and($resolvedAudit)->not->toBeNull() + ->and(data_get($resolvedAudit?->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION) + ->and(data_get($resolvedAudit?->metadata, 'verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_PENDING) + ->and(data_get($resolvedAudit?->metadata, 'report_bucket'))->toBe('remediation_pending_verification'); - $reopenedFinding = $service->reopen($resolvedFinding, $tenant, $user, 'The issue recurred after remediation.'); + $reopenedFinding = $service->reopen($resolvedFinding, $tenant, $user, Finding::REOPEN_REASON_MANUAL_REASSESSMENT); + $reopenedAudit = $this->latestFindingAudit($reopenedFinding, AuditActionId::FindingReopened); expect($reopenedFinding->status)->toBe(Finding::STATUS_REOPENED) ->and($reopenedFinding->reopened_at)->not->toBeNull() - ->and($this->latestFindingAudit($reopenedFinding, AuditActionId::FindingReopened))->not->toBeNull(); + ->and($reopenedAudit)->not->toBeNull() + ->and(data_get($reopenedAudit?->metadata, 'reopened_reason'))->toBe(Finding::REOPEN_REASON_MANUAL_REASSESSMENT) + ->and(data_get($reopenedAudit?->metadata, 'terminal_outcome_key'))->toBeNull(); expect(fn () => $service->startProgress($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user)) ->toThrow(\InvalidArgumentException::class, 'Finding cannot be moved to in-progress from the current status.'); @@ -118,14 +131,118 @@ ->toThrow(\InvalidArgumentException::class, 'closed_reason is required.'); }); +it('enforces canonical manual reason keys for terminal workflow mutations', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $service = app(FindingWorkflowService::class); + + expect(fn () => $service->resolve($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, 'patched')) + ->toThrow(\InvalidArgumentException::class, 'resolved_reason must be one of: remediated.'); + + expect(fn () => $service->close($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, 'not applicable')) + ->toThrow(\InvalidArgumentException::class, 'closed_reason must be one of: false_positive, duplicate, no_longer_applicable.'); + + expect(fn () => $service->reopen($this->makeFindingForWorkflow($tenant, Finding::STATUS_RESOLVED), $tenant, $user, 'please re-open')) + ->toThrow(\InvalidArgumentException::class, 'reopen_reason must be one of: recurred_after_resolution, verification_failed, manual_reassessment.'); + + expect(fn () => $service->riskAccept($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, 'accepted')) + ->toThrow(\InvalidArgumentException::class, 'closed_reason must be one of: accepted_risk.'); +}); + +it('records canonical close and risk-accept outcome metadata', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $service = app(FindingWorkflowService::class); + + $closed = $service->close( + $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), + $tenant, + $user, + Finding::CLOSE_REASON_DUPLICATE, + ); + $closedAudit = $this->latestFindingAudit($closed, AuditActionId::FindingClosed); + + expect($closed->status)->toBe(Finding::STATUS_CLOSED) + ->and($closed->closed_reason)->toBe(Finding::CLOSE_REASON_DUPLICATE) + ->and(data_get($closedAudit?->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE) + ->and(data_get($closedAudit?->metadata, 'report_bucket'))->toBe('administrative_closure'); + + $riskAccepted = $service->riskAccept( + $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), + $tenant, + $user, + Finding::CLOSE_REASON_ACCEPTED_RISK, + ); + $riskAudit = $this->latestFindingAudit($riskAccepted, AuditActionId::FindingRiskAccepted); + + expect($riskAccepted->status)->toBe(Finding::STATUS_RISK_ACCEPTED) + ->and($riskAccepted->closed_reason)->toBe(Finding::CLOSE_REASON_ACCEPTED_RISK) + ->and(data_get($riskAudit?->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED) + ->and(data_get($riskAudit?->metadata, 'report_bucket'))->toBe('accepted_risk'); +}); + +it('distinguishes verified clear from manual resolution in system transitions', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + CarbonImmutable::setTestNow('2026-04-23T10:00:00Z'); + + $service = app(FindingWorkflowService::class); + + $manualResolved = $service->resolve( + $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), + $tenant, + $user, + Finding::RESOLVE_REASON_REMEDIATED, + ); + + $verified = $service->resolveBySystem( + finding: $manualResolved, + tenant: $tenant, + reason: Finding::RESOLVE_REASON_NO_LONGER_DRIFTING, + resolvedAt: CarbonImmutable::parse('2026-04-23T11:00:00Z'), + ); + $verifiedAudit = $this->latestFindingAudit($verified, AuditActionId::FindingResolved); + + expect($verified->resolved_reason)->toBe(Finding::RESOLVE_REASON_NO_LONGER_DRIFTING) + ->and(data_get($verifiedAudit?->metadata, 'system_origin'))->toBeTrue() + ->and(data_get($verifiedAudit?->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED) + ->and(data_get($verifiedAudit?->metadata, 'verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_VERIFIED) + ->and(data_get($verifiedAudit?->metadata, 'report_bucket'))->toBe('remediation_verified'); + + $verificationFailed = $service->reopenBySystem( + finding: $service->resolve( + $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), + $tenant, + $user, + Finding::RESOLVE_REASON_REMEDIATED, + ), + tenant: $tenant, + reopenedAt: CarbonImmutable::parse('2026-04-23T12:00:00Z'), + ); + $verificationFailedAudit = $this->latestFindingAudit($verificationFailed, AuditActionId::FindingReopened); + + expect(data_get($verificationFailedAudit?->metadata, 'reopened_reason')) + ->toBe(Finding::REOPEN_REASON_VERIFICATION_FAILED); + + $recurred = $service->reopenBySystem( + finding: $verified, + tenant: $tenant, + reopenedAt: CarbonImmutable::parse('2026-04-23T13:00:00Z'), + ); + $recurredAudit = $this->latestFindingAudit($recurred, AuditActionId::FindingReopened); + + expect(data_get($recurredAudit?->metadata, 'reopened_reason')) + ->toBe(Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION); +}); + it('returns 403 for in-scope members without the required workflow capability', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); [$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly'); $finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW); - expect(fn () => app(FindingWorkflowService::class)->resolve($finding, $tenant, $readonly, 'patched')) + expect(fn () => app(FindingWorkflowService::class)->resolve($finding, $tenant, $readonly, Finding::RESOLVE_REASON_REMEDIATED)) ->toThrow(AuthorizationException::class); - expect(app(FindingWorkflowService::class)->resolve($finding, $tenant, $owner, 'patched')->status) + expect(app(FindingWorkflowService::class)->resolve($finding, $tenant, $owner, Finding::RESOLVE_REASON_REMEDIATED)->status) ->toBe(Finding::STATUS_RESOLVED); }); diff --git a/apps/platform/tests/Feature/Findings/FindingWorkflowViewActionsTest.php b/apps/platform/tests/Feature/Findings/FindingWorkflowViewActionsTest.php index 6f9df21c..6e164110 100644 --- a/apps/platform/tests/Feature/Findings/FindingWorkflowViewActionsTest.php +++ b/apps/platform/tests/Feature/Findings/FindingWorkflowViewActionsTest.php @@ -6,6 +6,7 @@ use App\Models\Finding; use App\Models\User; use Filament\Facades\Filament; +use Filament\Forms\Components\Select; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -21,7 +22,7 @@ $resolvedFinding = Finding::factory()->for($tenant)->create([ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => now(), - 'resolved_reason' => 'fixed', + 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED, ]); Livewire::test(ViewFinding::class, ['record' => $newFinding->getKey()]) @@ -38,8 +39,10 @@ ->assertActionVisible('reopen') ->mountAction('reopen') ->assertActionMounted('reopen') - ->callMountedAction() - ->assertHasActionErrors(['reopen_reason']); + ->assertFormFieldExists('reopen_reason', function (Select $field): bool { + return $field->getLabel() === 'Reopen reason' + && array_keys($field->getOptions()) === Finding::reopenReasonKeys(); + }); }); it('executes workflow actions from view header and supports assignment to tenant members only', function (): void { @@ -65,7 +68,7 @@ ]) ->assertHasNoActionErrors() ->callAction('resolve', [ - 'resolved_reason' => 'handled in queue', + 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED, ]) ->assertHasNoActionErrors(); @@ -77,12 +80,13 @@ Livewire::test(ViewFinding::class, ['record' => $finding->getKey()]) ->mountAction('reopen') ->assertActionMounted('reopen') - ->callMountedAction() - ->assertHasActionErrors(['reopen_reason']); + ->assertFormFieldExists('reopen_reason', function (Select $field): bool { + return array_keys($field->getOptions()) === Finding::reopenReasonKeys(); + }); Livewire::test(ViewFinding::class, ['record' => $finding->getKey()]) ->callAction('reopen', [ - 'reopen_reason' => 'The finding recurred after remediation.', + 'reopen_reason' => Finding::REOPEN_REASON_MANUAL_REASSESSMENT, ]) ->assertHasNoActionErrors() ->callAction('assign', [ diff --git a/apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php b/apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php index a833c01c..9c8134f8 100644 --- a/apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php +++ b/apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php @@ -5,6 +5,7 @@ use App\Filament\Resources\FindingResource\Pages\ListFindings; use App\Models\Finding; use App\Models\User; +use App\Support\Findings\FindingOutcomeSemantics; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -92,7 +93,7 @@ function findingFilterIndicatorLabels($component): array $historical = Finding::factory()->for($tenant)->create([ 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => now()->subDay(), - 'resolved_reason' => 'no_longer_drifting', + 'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING, ]); Livewire::test(ListFindings::class) @@ -109,6 +110,59 @@ function findingFilterIndicatorLabels($component): array ->assertCanNotSeeTableRecords([$active, $healthyAccepted, $historical]); }); +it('filters findings by canonical terminal outcome and verification state', function (): void { + [, $tenant] = actingAsFindingsManagerForFilters(); + + $pendingVerification = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED, + ]); + + $verifiedCleared = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_RESOLVED, + 'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING, + ]); + + $closedDuplicate = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_CLOSED, + 'closed_reason' => Finding::CLOSE_REASON_DUPLICATE, + ]); + + $riskAccepted = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_RISK_ACCEPTED, + 'closed_reason' => Finding::CLOSE_REASON_ACCEPTED_RISK, + ]); + + $openFinding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + ]); + + Livewire::test(ListFindings::class) + ->filterTable('terminal_outcome', FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION) + ->assertCanSeeTableRecords([$pendingVerification]) + ->assertCanNotSeeTableRecords([$verifiedCleared, $closedDuplicate, $riskAccepted, $openFinding]) + ->removeTableFilters() + ->filterTable('terminal_outcome', FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED) + ->assertCanSeeTableRecords([$verifiedCleared]) + ->assertCanNotSeeTableRecords([$pendingVerification, $closedDuplicate, $riskAccepted, $openFinding]) + ->removeTableFilters() + ->filterTable('terminal_outcome', FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE) + ->assertCanSeeTableRecords([$closedDuplicate]) + ->assertCanNotSeeTableRecords([$pendingVerification, $verifiedCleared, $riskAccepted, $openFinding]) + ->removeTableFilters() + ->filterTable('terminal_outcome', FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED) + ->assertCanSeeTableRecords([$riskAccepted]) + ->assertCanNotSeeTableRecords([$pendingVerification, $verifiedCleared, $closedDuplicate, $openFinding]) + ->removeTableFilters() + ->filterTable('verification_state', FindingOutcomeSemantics::VERIFICATION_PENDING) + ->assertCanSeeTableRecords([$pendingVerification]) + ->assertCanNotSeeTableRecords([$verifiedCleared, $closedDuplicate, $riskAccepted, $openFinding]) + ->removeTableFilters() + ->filterTable('verification_state', FindingOutcomeSemantics::VERIFICATION_VERIFIED) + ->assertCanSeeTableRecords([$verifiedCleared]) + ->assertCanNotSeeTableRecords([$pendingVerification, $closedDuplicate, $riskAccepted, $openFinding]); +}); + it('filters findings by high severity quick filter', function (): void { [, $tenant] = actingAsFindingsManagerForFilters(); diff --git a/apps/platform/tests/Feature/Models/FindingResolvedTest.php b/apps/platform/tests/Feature/Models/FindingResolvedTest.php index bc2f786f..8e7da394 100644 --- a/apps/platform/tests/Feature/Models/FindingResolvedTest.php +++ b/apps/platform/tests/Feature/Models/FindingResolvedTest.php @@ -12,11 +12,11 @@ [$user, $tenant] = createUserWithTenant(role: 'owner'); $finding = Finding::factory()->for($tenant)->permissionPosture()->create(); - $finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, 'permission_granted'); + $finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED); expect($finding->status)->toBe(Finding::STATUS_RESOLVED) ->and($finding->resolved_at)->not->toBeNull() - ->and($finding->resolved_reason)->toBe('permission_granted'); + ->and($finding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED); $fresh = Finding::query()->find($finding->getKey()); expect($fresh->status)->toBe(Finding::STATUS_RESOLVED) @@ -27,7 +27,7 @@ [$user, $tenant] = createUserWithTenant(role: 'owner'); $finding = Finding::factory()->for($tenant)->permissionPosture()->resolved()->create(); - $finding = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user, 'The finding recurred after a later scan.'); + $finding = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user, Finding::REOPEN_REASON_MANUAL_REASSESSMENT); expect($finding->status)->toBe(Finding::STATUS_REOPENED) ->and($finding->reopened_at)->not->toBeNull() @@ -39,11 +39,11 @@ [$user, $tenant] = createUserWithTenant(role: 'owner'); $finding = Finding::factory()->for($tenant)->permissionPosture()->create(); - $finding->resolve('permission_granted'); + $finding->resolve(Finding::RESOLVE_REASON_PERMISSION_GRANTED); expect($finding->status)->toBe(Finding::STATUS_RESOLVED) ->and($finding->resolved_at)->not->toBeNull() - ->and($finding->resolved_reason)->toBe('permission_granted'); + ->and($finding->resolved_reason)->toBe(Finding::RESOLVE_REASON_PERMISSION_GRANTED); $finding->reopen([ 'display_name' => 'Recovered Permission', @@ -103,7 +103,7 @@ expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED); - $finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, 'permission_granted'); + $finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED); expect($finding->status)->toBe(Finding::STATUS_RESOLVED) ->and($finding->acknowledged_at)->not->toBeNull() @@ -141,5 +141,5 @@ expect($finding->status)->toBe(Finding::STATUS_RESOLVED) ->and($finding->resolved_at)->not->toBeNull() - ->and($finding->resolved_reason)->toBe('permission_granted'); + ->and($finding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED); }); diff --git a/apps/platform/tests/Unit/Findings/FindingWorkflowServiceTest.php b/apps/platform/tests/Unit/Findings/FindingWorkflowServiceTest.php index 3cacac4b..629f1e15 100644 --- a/apps/platform/tests/Unit/Findings/FindingWorkflowServiceTest.php +++ b/apps/platform/tests/Unit/Findings/FindingWorkflowServiceTest.php @@ -35,7 +35,7 @@ 'severity' => Finding::SEVERITY_HIGH, 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => now()->subDay(), - 'resolved_reason' => 'fixed', + 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED, 'closed_at' => now()->subHours(2), 'closed_reason' => 'legacy-close', 'closed_by_user_id' => $user->getKey(), @@ -43,7 +43,7 @@ 'due_at' => now()->subDays(10), ]); - $reopened = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user, 'The issue recurred after verification.'); + $reopened = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user, Finding::REOPEN_REASON_MANUAL_REASSESSMENT); expect($reopened->status)->toBe(Finding::STATUS_REOPENED) ->and($reopened->reopened_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00') diff --git a/specs/231-finding-outcome-taxonomy/checklists/requirements.md b/specs/231-finding-outcome-taxonomy/checklists/requirements.md new file mode 100644 index 00000000..abec6c32 --- /dev/null +++ b/specs/231-finding-outcome-taxonomy/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Finding Outcome Taxonomy & Verification Semantics + +**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 in one pass. No open clarification markers remain. +- The spec keeps the existing findings lifecycle and scopes the change to one bounded outcome taxonomy plus verification semantics; no new queue, entity, or status family is introduced. +- Repo-required constitution and surface-guardrail references are treated as product governance constraints, not implementation design leakage. \ No newline at end of file diff --git a/specs/231-finding-outcome-taxonomy/contracts/finding-outcome-taxonomy.logical.openapi.yaml b/specs/231-finding-outcome-taxonomy/contracts/finding-outcome-taxonomy.logical.openapi.yaml new file mode 100644 index 00000000..f5128c07 --- /dev/null +++ b/specs/231-finding-outcome-taxonomy/contracts/finding-outcome-taxonomy.logical.openapi.yaml @@ -0,0 +1,397 @@ +openapi: 3.1.0 +info: + title: Finding Outcome Taxonomy Logical Contract + version: 0.1.0 + description: | + Logical contract for the findings outcome taxonomy feature. This is not a new + public HTTP API commitment. It documents the request and response shapes that + existing Filament and service workflows must converge on. + +servers: + - url: https://tenantpilot.local + description: Logical base URL only + +tags: + - name: Findings + - name: FindingsInternal + +paths: + /tenants/{tenantId}/findings: + get: + tags: [Findings] + summary: List tenant findings with terminal-outcome filters + operationId: listTenantFindings + parameters: + - $ref: '#/components/parameters/TenantId' + - name: status + in: query + schema: + $ref: '#/components/schemas/FindingStatus' + - name: terminal_outcome + in: query + schema: + $ref: '#/components/schemas/TerminalOutcomeKey' + - name: verification_state + in: query + schema: + $ref: '#/components/schemas/VerificationState' + responses: + '200': + description: Tenant findings list + content: + application/json: + schema: + type: object + required: [data] + properties: + data: + type: array + items: + $ref: '#/components/schemas/FindingSummary' + + /tenants/{tenantId}/findings/{findingId}: + get: + tags: [Findings] + summary: Get one finding with current terminal-outcome semantics + operationId: getTenantFinding + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/FindingId' + responses: + '200': + description: Finding detail + content: + application/json: + schema: + $ref: '#/components/schemas/FindingDetail' + + /tenants/{tenantId}/findings/{findingId}/resolve: + post: + tags: [Findings] + summary: Resolve a finding with a bounded operator reason + operationId: resolveTenantFinding + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/FindingId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveFindingRequest' + responses: + '200': + description: Finding resolved pending verification + content: + application/json: + schema: + $ref: '#/components/schemas/FindingDetail' + + /tenants/{tenantId}/findings/{findingId}/close: + post: + tags: [Findings] + summary: Close a finding with a bounded administrative reason + operationId: closeTenantFinding + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/FindingId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CloseFindingRequest' + responses: + '200': + description: Finding closed with a non-remediation outcome + content: + application/json: + schema: + $ref: '#/components/schemas/FindingDetail' + + /tenants/{tenantId}/findings/{findingId}/reopen: + post: + tags: [Findings] + summary: Reopen a terminal finding with a bounded reopen reason + operationId: reopenTenantFinding + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/FindingId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReopenFindingRequest' + responses: + '200': + description: Finding reopened + content: + application/json: + schema: + $ref: '#/components/schemas/FindingDetail' + + /internal/tenants/{tenantId}/findings/{findingId}/system-clear: + post: + tags: [FindingsInternal] + summary: Apply a trusted system-clear reason + operationId: systemClearFinding + x-internal: true + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/FindingId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SystemClearFindingRequest' + responses: + '200': + description: Finding moved into a verified-cleared outcome + content: + application/json: + schema: + $ref: '#/components/schemas/FindingDetail' + + /internal/tenants/{tenantId}/findings/{findingId}/system-reopen: + post: + tags: [FindingsInternal] + summary: Reopen a terminal finding due to trusted recurrence or verification failure + operationId: systemReopenFinding + x-internal: true + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/FindingId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SystemReopenFindingRequest' + responses: + '200': + description: Finding reopened by trusted automation + content: + application/json: + schema: + $ref: '#/components/schemas/FindingDetail' + +components: + parameters: + TenantId: + name: tenantId + in: path + required: true + schema: + type: integer + FindingId: + name: findingId + in: path + required: true + schema: + type: integer + + schemas: + FindingStatus: + type: string + enum: + - new + - acknowledged + - triaged + - in_progress + - reopened + - resolved + - closed + - risk_accepted + + VerificationState: + type: string + enum: + - pending_verification + - verified_cleared + - not_applicable + + ResolveReasonKey: + type: string + enum: + - remediated + - no_longer_drifting + - permission_granted + - permission_removed_from_registry + - role_assignment_removed + - ga_count_within_threshold + + CloseReasonKey: + type: string + enum: + - false_positive + - duplicate + - no_longer_applicable + - accepted_risk + + ReopenReasonKey: + type: string + enum: + - recurred_after_resolution + - verification_failed + - manual_reassessment + + TerminalOutcomeKey: + type: string + enum: + - resolved_pending_verification + - verified_cleared + - closed_false_positive + - closed_duplicate + - closed_no_longer_applicable + - risk_accepted + + TerminalOutcome: + type: object + required: + - key + - label + - verification_state + - report_bucket + properties: + key: + $ref: '#/components/schemas/TerminalOutcomeKey' + label: + type: string + verification_state: + $ref: '#/components/schemas/VerificationState' + report_bucket: + type: string + enum: + - remediation_pending_verification + - remediation_verified + - administrative_closure + - accepted_risk + governance_state: + type: string + nullable: true + description: Present only when the outcome depends on risk-governance validity. + + ResolveFindingRequest: + type: object + required: [reason] + properties: + reason: + type: string + enum: [remediated] + note: + type: string + maxLength: 255 + nullable: true + + CloseFindingRequest: + type: object + required: [reason] + properties: + reason: + type: string + enum: + - false_positive + - duplicate + - no_longer_applicable + note: + type: string + maxLength: 255 + nullable: true + + ReopenFindingRequest: + type: object + required: [reason] + properties: + reason: + $ref: '#/components/schemas/ReopenReasonKey' + note: + type: string + maxLength: 255 + nullable: true + + SystemClearFindingRequest: + type: object + required: [reason, observed_at] + properties: + reason: + type: string + enum: + - no_longer_drifting + - permission_granted + - permission_removed_from_registry + - role_assignment_removed + - ga_count_within_threshold + observed_at: + type: string + format: date-time + operation_run_id: + type: integer + nullable: true + + SystemReopenFindingRequest: + type: object + required: [reason, observed_at] + properties: + reason: + type: string + enum: + - recurred_after_resolution + - verification_failed + observed_at: + type: string + format: date-time + operation_run_id: + type: integer + nullable: true + + FindingSummary: + type: object + required: + - id + - tenant_id + - status + - severity + - terminal_outcome + properties: + id: + type: integer + tenant_id: + type: integer + status: + $ref: '#/components/schemas/FindingStatus' + severity: + type: string + resolved_reason: + oneOf: + - $ref: '#/components/schemas/ResolveReasonKey' + - type: 'null' + closed_reason: + oneOf: + - $ref: '#/components/schemas/CloseReasonKey' + - type: 'null' + terminal_outcome: + $ref: '#/components/schemas/TerminalOutcome' + + FindingDetail: + allOf: + - $ref: '#/components/schemas/FindingSummary' + - type: object + properties: + resolved_at: + type: string + format: date-time + nullable: true + closed_at: + type: string + format: date-time + nullable: true + reopened_at: + type: string + format: date-time + nullable: true + audit_context: + type: object + additionalProperties: true + description: Logical placeholder for the readable audit/history payload. \ No newline at end of file diff --git a/specs/231-finding-outcome-taxonomy/data-model.md b/specs/231-finding-outcome-taxonomy/data-model.md new file mode 100644 index 00000000..8e00f867 --- /dev/null +++ b/specs/231-finding-outcome-taxonomy/data-model.md @@ -0,0 +1,174 @@ +# Data Model: Finding Outcome Taxonomy & Verification Semantics + +## Overview + +This feature does not add a new table or a new top-level persisted entity. It reuses the current `findings` row as the source of truth for current terminal meaning, keeps reopen rationale in audit metadata, and derives verification or reporting buckets through one findings-local semantics helper. + +## Entity: Finding + +**Persistence**: existing `findings` table +**Owner**: tenant-owned record +**Primary responsibility**: current workflow status, current terminal-outcome key, timestamps, and tenant-scoped operational ownership + +### Relevant persisted fields + +| Field | Type | Source | Notes | +|------|------|--------|-------| +| `id` | integer | existing | Primary key | +| `workspace_id` | integer | existing | Workspace isolation boundary | +| `tenant_id` | integer | existing | Tenant isolation boundary | +| `status` | string | existing | Primary lifecycle status; remains one of `new`, `acknowledged`, `triaged`, `in_progress`, `reopened`, `resolved`, `closed`, `risk_accepted` | +| `severity` | string | existing | Existing priority and SLA signal | +| `resolved_reason` | string nullable | existing | Becomes a bounded canonical resolve key instead of free-form prose | +| `closed_reason` | string nullable | existing | Becomes a bounded canonical close key and remains the stored reason for `risk_accepted` | +| `resolved_at` | datetime nullable | existing | Marks the current resolved terminal timestamp | +| `closed_at` | datetime nullable | existing | Marks the current closed or risk-accepted terminal timestamp | +| `reopened_at` | datetime nullable | existing | Marks the current reopen timestamp | +| `closed_by_user_id` | integer nullable | existing | Current actor for close and risk-accept paths | +| `owner_user_id` | integer nullable | existing | Accountability owner | +| `assignee_user_id` | integer nullable | existing | Active assignee | +| `evidence_jsonb` | jsonb | existing | Current supporting evidence; unchanged in this slice | + +### Relationships + +| Relationship | Type | Notes | +|-------------|------|-------| +| `tenant()` | belongsTo | Tenant scope owner | +| `ownerUser()` | belongsTo | Accountability owner | +| `assigneeUser()` | belongsTo | Active assignee | +| `closedByUser()` | belongsTo | Actor for close/risk accept | +| `findingException()` | hasOne | Existing risk-governance truth from Spec 154 | + +### Validation rules introduced by this feature + +| Rule | Description | +|------|-------------| +| `status` | No new status is introduced; all existing transition rules stay in force | +| `resolved_reason` | Required for resolve and system-clear transitions; must be a canonical key from the resolve-reason family | +| `closed_reason` | Required for close and risk-accept transitions; must be a canonical key from the close-reason family or the risk-accept key | +| `reopen_reason` | Required for reopen transitions; remains audit metadata and must be a canonical key from the reopen-reason family | + +### Canonical reason families + +#### Resolve reason keys + +| Key | Meaning | Derived verification state | +|-----|---------|----------------------------| +| `remediated` | Operator declares that remediation work was completed | `pending_verification` | +| `no_longer_drifting` | Trusted compare no longer reproduces prior drift | `verified_cleared` | +| `permission_granted` | Trusted permission evidence no longer reproduces the finding condition | `verified_cleared` | +| `permission_removed_from_registry` | Trusted permission evidence confirms the triggering permission was removed | `verified_cleared` | +| `role_assignment_removed` | Trusted role evidence confirms the triggering assignment was removed | `verified_cleared` | +| `ga_count_within_threshold` | Trusted role evidence confirms the triggering count is now safe | `verified_cleared` | + +#### Close reason keys + +| Key | Meaning | Outcome family | +|-----|---------|----------------| +| `false_positive` | The finding should not have been actionable | `administrative_closure` | +| `duplicate` | The finding duplicates another case | `administrative_closure` | +| `no_longer_applicable` | The finding no longer applies to the tenant context | `administrative_closure` | +| `accepted_risk` | Existing accepted-risk path only; still governed by exception validity | `accepted_risk` | + +#### Reopen reason keys + +| Key | Meaning | Persistence | +|-----|---------|-------------| +| `recurred_after_resolution` | A previously addressed condition reappeared | audit metadata only | +| `verification_failed` | Trusted evidence contradicted the earlier resolved outcome | audit metadata only | +| `manual_reassessment` | An operator reopened the finding after review | audit metadata only | + +### Derived facets from the current row + +| Derived facet | Source | Meaning | +|--------------|--------|---------| +| `verification_state` | `status` + `resolved_reason` | `pending_verification`, `verified_cleared`, or `not_applicable` | +| `terminal_outcome_key` | `status` + canonical reason key | Stable UI/reporting key such as `resolved_pending_verification`, `verified_cleared`, `closed_false_positive`, `closed_duplicate`, `closed_no_longer_applicable`, or `risk_accepted` | +| `report_bucket` | `terminal_outcome_key` + governance validity | Report-friendly aggregation bucket | +| `outcome_label` | `terminal_outcome_key` | Canonical operator wording | + +### State transitions + +| From | Action | Stored result | Derived outcome | +|------|--------|---------------|-----------------| +| `new`, `triaged`, `in_progress`, `reopened`, `acknowledged` | `resolve(remediated)` | `status=resolved`, `resolved_reason=remediated` | `Resolved pending verification` | +| open statuses | `close(false_positive)` | `status=closed`, `closed_reason=false_positive` | `Closed as false positive` | +| open statuses | `close(duplicate)` | `status=closed`, `closed_reason=duplicate` | `Closed as duplicate` | +| open statuses | `close(no_longer_applicable)` | `status=closed`, `closed_reason=no_longer_applicable` | `Closed as no longer applicable` | +| open statuses | `riskAccept(accepted_risk)` | `status=risk_accepted`, `closed_reason=accepted_risk` | `Risk accepted` with separate governance validity | +| `resolved` with `resolved_reason=remediated` | trusted system clear | `status=resolved`, `resolved_reason=` | `Verified cleared` | +| open statuses | direct trusted system clear | `status=resolved`, `resolved_reason=` | `Verified cleared` | +| `resolved`, `closed`, `risk_accepted` | `reopen()` | `status=reopened`, terminal reason fields cleared, reopen reason recorded in audit metadata | open finding again | + +## Entity: FindingException + +**Persistence**: existing `finding_exceptions` table +**Owner**: tenant-owned record +**Primary responsibility**: governance validity for `risk_accepted` + +### Relevant fields consumed by this feature + +| Field | Type | Notes | +|------|------|-------| +| `status` | string | Existing workflow for exception requests and approvals | +| `current_validity_state` | string | Current validity used by `FindingRiskGovernanceResolver` | +| `effective_from` | datetime nullable | Existing validity window | +| `expires_at` | datetime nullable | Existing validity window | +| `review_due_at` | datetime nullable | Existing governance follow-up signal | + +### Rule in this feature + +- `FindingException` continues to determine whether `risk_accepted` is governed safely. +- `FindingException` does not contribute to `verified_cleared` and does not change remediation buckets. + +## Derived read model: FindingOutcomeSemantics + +**Persistence**: not persisted +**Owner**: findings-local support helper +**Primary responsibility**: unify list, detail, filter, and reporting semantics from current finding truth + +### Inputs + +| Input | Source | +|------|--------| +| `status` | `Finding` row | +| `resolved_reason` | `Finding` row | +| `closed_reason` | `Finding` row | +| `findingException` validity | `FindingException` relationship via existing resolver | +| `system_origin` and prior workflow steps | audit metadata, only when reconstructing history or detailed provenance | + +### Outputs + +| Output | Type | Notes | +|-------|------|-------| +| `terminalOutcomeKey` | string | Stable internal key | +| `label` | string | Canonical operator-facing wording | +| `verificationState` | string | `pending_verification`, `verified_cleared`, `not_applicable` | +| `reportBucket` | string | Aggregation bucket for reviews and exports | +| `historicalContext` | string nullable | Reuses current resolver-style explanatory text where appropriate | + +### Report bucket mapping + +| Terminal outcome key | Report bucket | +|----------------------|---------------| +| `resolved_pending_verification` | `remediation_pending_verification` | +| `verified_cleared` | `remediation_verified` | +| `closed_false_positive` | `administrative_closure` | +| `closed_duplicate` | `administrative_closure` | +| `closed_no_longer_applicable` | `administrative_closure` | +| `risk_accepted` | `accepted_risk` | + +## Audit metadata additions or constraints + +| Key | Existing/New | Purpose | +|-----|--------------|---------| +| `resolved_reason` | existing | Canonical current resolve key | +| `closed_reason` | existing | Canonical current close or risk-accept key | +| `reopened_reason` | existing | Canonical reopen key for reviewability | +| `system_origin` | existing | Provenance flag for system transitions | +| `resolved_at`, `closed_at`, `reopened_at` | existing | Timeline reconstruction | + +### Audit rule + +- Audit metadata remains the place to reconstruct path history, such as whether a verified-clear outcome happened directly through automation or after a prior manual `remediated` transition. +- The current row remains the source of truth for current filters and summaries. \ No newline at end of file diff --git a/specs/231-finding-outcome-taxonomy/plan.md b/specs/231-finding-outcome-taxonomy/plan.md new file mode 100644 index 00000000..6ce487f9 --- /dev/null +++ b/specs/231-finding-outcome-taxonomy/plan.md @@ -0,0 +1,259 @@ +# Implementation Plan: Finding Outcome Taxonomy & Verification Semantics + +**Branch**: `231-finding-outcome-taxonomy` | **Date**: 2026-04-22 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/231-finding-outcome-taxonomy/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/231-finding-outcome-taxonomy/spec.md` + +**Note**: This plan keeps the work inside the existing findings workflow, tenant findings resource, current risk-governance interpretation, shared findings action catalog copy, pre-production lifecycle normalization jobs, system resolve/reopen paths, and current review/report consumers. The intended implementation adds one bounded outcome-semantics seam over existing `Finding` records and existing transition services. It does not add a new findings queue, a new panel, a new asset family, a second workflow state store, or a new primary findings status. + +## Summary + +Introduce one bounded findings outcome taxonomy that sits on top of the existing lifecycle in `FindingWorkflowService` and current `Finding` records. Keep `status` unchanged, tighten `resolved_reason`, `closed_reason`, and `reopen_reason` into canonical keys, distinguish operator-declared resolution from later trusted system-cleared outcomes, and apply that same contract to `FindingResource`, the existing list and detail narratives, system resolve/reopen producers such as `BaselineAutoCloseService`, `EntraAdminRolesFindingGenerator`, `PermissionPostureFindingGenerator`, and `CompareBaselineToTenantJob`, supporting pre-production normalization jobs, shared findings action copy in `GovernanceActionCatalog`, the risk-governance interpreter in `FindingRiskGovernanceResolver`, plus current findings-derived review/report consumers such as `ReviewRegister` and `TenantReviewResource`. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade +**Primary Dependencies**: `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Findings\FindingRiskGovernanceResolver`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Jobs\BackfillFindingLifecycleJob`, `App\Jobs\BackfillFindingLifecycleTenantIntoWorkspaceRunJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `App\Support\Ui\GovernanceActions\GovernanceActionCatalog`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger` +**Storage**: PostgreSQL via existing `findings`, `finding_exceptions`, `tenant_reviews`, `stored_reports`, and audit-log tables; no schema changes planned +**Testing**: Pest v4 feature tests with Filament/Livewire assertions and workflow-service coverage +**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 findings list and review/report rollups free of new N+1 behavior, keep terminal-outcome rendering derived from existing record state, and avoid new background or polling work +**Constraints**: No new primary findings status, no new queue surface, no new Graph calls, no new asset family, no cross-tenant leakage, no comments or attachments scope, and no second reporting-only persistence layer +**Scale/Scope**: One narrow semantics helper or equivalent local mapping seam, one workflow-service hardening slice, one risk-governance alignment slice, four existing system producer paths, two pre-production normalization jobs, one shared action-catalog touchpoint, two existing operator surfaces, and a small set of focused test suites + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament resource list, native record detail, and existing review/report surfaces only +- **Shared-family relevance**: findings workflow surfaces, status messaging, filters, review/report viewers, centralized badge semantics +- **State layers in scope**: page, detail, shell +- **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 must converge all in-scope outcome language on one bounded semantics seam rather than permitting page-local synonyms +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: + - `App\Filament\Resources\FindingResource` + - `App\Services\Findings\FindingWorkflowService` + - `App\Services\Findings\FindingRiskGovernanceResolver` + - `App\Models\Finding` + - `App\Services\Baselines\BaselineAutoCloseService` + - `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator` + - `App\Services\PermissionPosture\PermissionPostureFindingGenerator` + - `App\Jobs\CompareBaselineToTenantJob` + - `App\Jobs\BackfillFindingLifecycleJob` + - `App\Jobs\BackfillFindingLifecycleTenantIntoWorkspaceRunJob` + - `App\Filament\Pages\Reviews\ReviewRegister` + - `App\Filament\Resources\TenantReviewResource` + - `App\Support\Ui\GovernanceActions\GovernanceActionCatalog` + - centralized badge and audit metadata paths +- **Shared abstractions reused**: existing `FindingWorkflowService`, existing `Finding` model lifecycle constants, existing `BadgeCatalog` and `BadgeRenderer`, existing `GovernanceActionCatalog` copy contracts, existing review/register consumers, and current audit metadata structure in `FindingWorkflowService` +- **New abstraction introduced? why?**: one narrow `FindingOutcomeSemantics`-style helper or equivalent local seam is justified because the same terminal-outcome meaning must be reused by workflow mutations, list/detail narratives, filters, and review/report rollups +- **Why the existing abstraction was sufficient or insufficient**: the existing findings lifecycle is sufficient as the primary workflow status layer, but it is insufficient for terminal-outcome meaning because free-form reasons and page-local narratives cannot reliably separate operator-resolved, system-verified, and non-remediation closure outcomes +- **Bounded deviation / spread control**: no parallel presentation path is allowed; if a review/report consumer needs compressed wording, it must still derive from the same canonical keys and verified-clear rules as the operator surfaces + +## Constitution Check + +*GATE: Passed before implementation design. Re-check after any scope expansion.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| Read/write separation | PASS | The feature only tightens meaning on existing finding transitions and existing read models; no new write family is introduced | +| Single contract path / no Graph bypass | PASS | No Graph calls or contract-registry changes are involved | +| Deterministic capabilities / RBAC-UX | PASS | Existing tenant findings permissions remain authoritative; no new capability family or plane change is introduced | +| Workspace and tenant isolation | PASS | All touched operator surfaces remain tenant-entitlement scoped; review/report rollups must continue to hide unauthorized tenant data | +| No new persisted truth without need | PASS | Existing `resolved_reason`, `closed_reason`, audit metadata, and exception validity semantics are reused; no new table or artifact is planned | +| No new state without behavioral consequence | PASS | The plan keeps the primary lifecycle statuses unchanged and limits new semantics to terminal-outcome meaning that changes filters, audit reading, and reporting buckets | +| No premature abstraction / few layers | PASS | One narrow helper is the maximum justified addition because at least four producer paths and multiple readers need the same semantics | +| Shared pattern first | PASS | One shared outcome semantics seam will feed workflow actions, UI narratives, filters, and reporting rather than separate local mappings | +| Badge semantics (BADGE-001) | PASS | Existing centralized badge/rendering rules remain authoritative; no page-local color language is planned | +| Filament-native UI (UI-FIL-001) | PASS | Findings list/detail and review surfaces stay on existing Filament resources/pages with updated filters, modals, and narratives only | +| Livewire v4.0+ / Filament v5 compliance | PASS | The plan stays within existing Filament v5 and Livewire v4 surface patterns | +| Provider registration / global search / assets | PASS | Panel providers remain in `apps/platform/bootstrap/providers.php`; no new globally searchable resource, no new asset family, and no deploy-step change beyond the existing `cd apps/platform && php artisan filament:assets` policy | +| Test governance (TEST-GOV-001) | PASS | Proof stays in focused feature suites; no browser or heavy-governance expansion is planned | + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Feature` for workflow-service semantics, system resolve/reopen paths, list/detail terminal-outcome presentation, filters, and findings-derived review/report buckets +- **Affected validation lanes**: `fast-feedback`, `confidence` +- **Why this lane mix is the narrowest sufficient proof**: The risk is integrated business meaning across existing findings mutations and existing operator or report consumers. Focused feature tests prove that meaning without adding browser rendering or heavy-governance breadth. +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowServiceTest.php tests/Feature/Findings/FindingRecurrenceTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php` +- **Fixture / helper / factory / seed / context cost risks**: Moderate. Tests need open, resolved, closed, reopened, and risk-accepted findings; system actor paths that clear or reopen findings; valid and invalid exception coverage; and at least one findings-derived review/report scenario. +- **Expensive defaults or shared helper growth introduced?**: no; any new helpers should stay findings-local and reuse existing findings workflow test concerns +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: `standard-native-filament`; keep coverage centered on existing resource and service seams instead of adding browser tests +- **Closing validation and reviewer handoff**: Reviewers should verify that no new primary findings status was added, that canonical keys replaced free-form reporting meaning, that `risk_accepted` stayed governed by exception validity, that system-cleared findings are distinct from operator-resolved findings, and that the same semantics appear in workflow service, list filters, detail narrative, and report buckets. +- **Budget / baseline / trend follow-up**: none +- **Review-stop questions**: Did the implementation add a new status instead of a derived outcome layer? Did any producer path keep a free-form-only reason as the primary meaning? Did review/report code invent a second bucket taxonomy? Did risk-accepted findings collapse into verified-clear logic? +- **Escalation path**: document-in-feature unless implementation pressure requires a schema change or a second cross-domain presenter, 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**: The current operator and reporting gap can be closed inside the existing findings workflow and report seams without broader framework work + +## Project Structure + +### Documentation (this feature) + +```text +specs/231-finding-outcome-taxonomy/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── finding-outcome-taxonomy.logical.openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/ +│ │ │ └── Reviews/ +│ │ │ └── ReviewRegister.php +│ │ └── Resources/ +│ │ ├── FindingResource.php +│ │ └── TenantReviewResource.php +│ ├── Jobs/ +│ │ └── CompareBaselineToTenantJob.php +│ ├── Models/ +│ │ ├── Finding.php +│ │ └── FindingException.php +│ ├── Services/ +│ │ ├── Baselines/ +│ │ │ └── BaselineAutoCloseService.php +│ │ ├── EntraAdminRoles/ +│ │ │ └── EntraAdminRolesFindingGenerator.php +│ │ ├── Findings/ +│ │ │ └── FindingWorkflowService.php +│ │ └── PermissionPosture/ +│ │ └── PermissionPostureFindingGenerator.php +│ └── Support/ +│ ├── Badges/ +│ │ ├── BadgeCatalog.php +│ │ ├── BadgeDomain.php +│ │ └── BadgeRenderer.php +│ └── Findings/ +│ └── FindingOutcomeSemantics.php +└── tests/ + └── Feature/ + ├── Filament/ + │ └── FindingResolvedReferencePresentationTest.php + └── Findings/ + ├── FindingWorkflowServiceTest.php + ├── FindingRecurrenceTest.php + ├── FindingRiskGovernanceProjectionTest.php + ├── FindingOutcomeSummaryReportingTest.php + └── FindingsListFiltersTest.php +``` + +**Structure Decision**: Standard Laravel monolith. The work stays inside the existing findings workflow, system finding-generator seams, and current report/register consumers. No new base directory, no new panel, and no new persistence layer are required. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| One narrow findings-outcome semantics seam | Multiple producer and consumer paths need the same terminal-outcome meaning | Keeping meaning inside `FindingResource` static methods or free-form workflow-service strings would let reporting and automation drift immediately | + +## Proportionality Review + +- **Current operator problem**: Terminal findings are currently too ambiguous for operators and reviewers to tell whether the issue was fixed, only declared fixed, system-confirmed clear, or closed as non-actionable. +- **Existing structure is insufficient because**: The current lifecycle status plus free-form reasons cannot safely drive filters, audit interpretation, and report rollups with the same meaning. +- **Narrowest correct implementation**: Preserve the existing status model and existing finding rows, add bounded canonical reason keys plus one narrow verified-clear interpretation seam, and reuse it across workflow producers and readers. +- **Ownership cost created**: One findings-local semantics helper or equivalent seam, updates to workflow-service validation and audit metadata, updates to current generators and readers, and focused regression coverage. +- **Alternative intentionally rejected**: A new primary findings status such as `verified`, or a generic governance-case framework, was rejected because both add broader workflow complexity than current release truth requires. +- **Release truth**: Current-release truth. This feature corrects the meaning of today's findings workflow and today's downstream review/report consumers. + +## Planned Phase Outputs + +- `research.md`: map current free-form reason usage in `FindingWorkflowService`, current system resolve/reopen producers, and current report/list consumers that read terminal findings +- `data-model.md`: document the existing `Finding` fields, the canonical reason families, the derived verification meaning, and the report-bucket mapping +- `quickstart.md`: record the narrowest implementation and validation workflow for service, UI, and reporting changes +- `contracts/finding-outcome-taxonomy.logical.openapi.yaml`: describe the logical transition inputs and terminal-outcome outputs for list/detail/report consumers + +## Implementation Strategy + +### Phase A - Define the bounded outcome taxonomy close to the finding domain + +**Goal**: Create one canonical source for resolve, close, reopen, and verification meaning without changing primary lifecycle status. + +| Step | File | Change | +|------|------|--------| +| A.1 | `apps/platform/app/Models/Finding.php` | Add or centralize canonical reason-key lists and any small helper methods needed to describe terminal-outcome meaning while preserving existing status constants | +| A.2 | `apps/platform/app/Support/Findings/FindingOutcomeSemantics.php` | Add one narrow findings-local helper that maps current finding state, reason keys, and exception validity into operator-safe outcome labels, verification meaning, and report buckets | +| A.3 | `apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php` | Keep accepted-risk historical context and warning semantics aligned to the same canonical outcome buckets without collapsing exception validity into verified-clear meaning | +| A.4 | `apps/platform/app/Support/Badges/*` or existing finding narrative paths | Reuse centralized badge and narrative rules for any new emphasis instead of page-local color or label mappings | + +### Phase B - Harden workflow transitions and audit metadata around canonical reasons + +**Goal**: Make manual and system transitions persist canonical meaning instead of free-form semantics. + +| Step | File | Change | +|------|------|--------| +| B.1 | `apps/platform/app/Services/Findings/FindingWorkflowService.php` | Replace free-form-only `validatedReason()` behavior for resolve, close, and reopen with canonical bounded keys plus optional secondary note support if needed | +| B.2 | `apps/platform/app/Services/Findings/FindingWorkflowService.php` | Extend audit metadata and snapshots so resolve, close, reopen, and system-origin transitions preserve structured outcome meaning and verification-safe context | +| B.3 | `apps/platform/app/Services/Findings/FindingWorkflowService.php` | Keep `status` unchanged, but ensure system resolve and system reopen paths clear or set the derived verified-clear interpretation consistently | +| B.4 | `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php` and `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php` | Replace legacy reason strings in current pre-production lifecycle normalization paths so canonical keys stay single-source during backfill and workspace-run repair flows | + +### Phase C - Align system producers to the same system-clear and recurrence semantics + +**Goal**: Remove semantic drift between manual workflow actions and automated finding producers. + +| Step | File | Change | +|------|------|--------| +| C.1 | `apps/platform/app/Services/Baselines/BaselineAutoCloseService.php` | Use canonical system resolve reasons for auto-cleared findings | +| C.2 | `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` and `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` | Route system resolve and reopen paths through the same canonical keys and verified-clear semantics | +| C.3 | `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` | Keep recurrence-driven reopen behavior aligned with the same structured reopen reasons and verified-clear reset rules | + +### Phase D - Update operator surfaces without creating a second findings UX language + +**Goal**: Make list, detail, and action modals speak the same terminal-outcome language. + +| Step | File | Change | +|------|------|--------| +| D.1 | `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php` | Align shared findings action labels, helper copy, and reason policy metadata with the canonical resolve, close, and reopen vocabulary | +| D.2 | `apps/platform/app/Filament/Resources/FindingResource.php` | Update grouped resolve, close, and reopen actions to present canonical options and secondary help text instead of free-form-only terminal meaning | +| D.3 | `apps/platform/app/Filament/Resources/FindingResource.php` | Update list filters, default-visible terminal summaries, and detail narratives to consume the shared semantics helper | +| D.4 | `apps/platform/app/Filament/Resources/FindingResource.php` | Preserve open-backlog defaults from Spec 111 and keep `risk_accepted` distinct through existing governance-validity signals | + +### Phase E - Align findings-derived review and report consumers + +**Goal**: Keep downstream consumers from inventing a second outcome taxonomy. + +| Step | File | Change | +|------|------|--------| +| E.1 | `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php` | Update any findings-derived bucket or label usage to consume the shared outcome semantics instead of local wording | +| E.2 | `apps/platform/app/Filament/Resources/TenantReviewResource.php` | Keep review-pack, executive-pack export cues, and tenant review summary presentation aligned with the same terminal-outcome buckets and verified-clear rules | + +### Phase F - Prove the taxonomy through focused regression coverage + +**Goal**: Lock workflow, UI, and reporting semantics together with the smallest sufficient test set. + +| Step | File | Change | +|------|------|--------| +| F.1 | `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php` | Extend transition tests for canonical resolve, close, and reopen reasons plus audit metadata | +| F.2 | `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php` | Prove recurrence reopens remove verified-clear interpretation and keep structured reopen reasons | +| F.3 | `apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php` and `apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php` | Prove list filters and detail presentation distinguish `Resolved pending verification`, `Verified cleared`, and non-remediation closure | +| F.4 | `apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php` and `apps/platform/tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php` | Prove report and governance projections keep `risk_accepted`, verified-cleared, and closed-non-remediation buckets distinct | +| F.5 | `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` plus the focused Pest commands above | Run formatting and the narrow proving set before implementation close-out | + +## Post-Design Constitution Re-check + +- **Read/write separation**: PASS. The design still changes only existing findings transitions and existing read consumers. +- **Persisted truth**: PASS. No new table, entity, or second semantic store was introduced by the design artifacts. +- **Behavioral state**: PASS. Verification meaning stays derived from bounded keys and existing status, not from a new primary state. +- **Abstraction control**: PASS. The design still justifies only one findings-local semantics helper. +- **Filament / Livewire / panel safety**: PASS. The design remains within Filament v5 and Livewire v4, keeps provider registration unchanged in `bootstrap/providers.php`, does not add a new globally searchable resource, and does not introduce a new asset family beyond the existing `filament:assets` deploy policy. +- **Testing governance**: PASS. Proof remains in focused feature suites and does not widen into browser or heavy-governance lanes. \ No newline at end of file diff --git a/specs/231-finding-outcome-taxonomy/quickstart.md b/specs/231-finding-outcome-taxonomy/quickstart.md new file mode 100644 index 00000000..461558da --- /dev/null +++ b/specs/231-finding-outcome-taxonomy/quickstart.md @@ -0,0 +1,87 @@ +# Quickstart: Finding Outcome Taxonomy & Verification Semantics + +## Goal + +Implement one bounded findings outcome taxonomy on top of the existing findings lifecycle so that: + +- operator-resolved findings surface as `Resolved pending verification` +- trusted system-cleared findings surface as `Verified cleared` +- non-remediation closures stay separate +- `risk_accepted` remains governed by exception validity instead of collapsing into remediation semantics + +## Prerequisites + +- Work on branch `231-finding-outcome-taxonomy` +- Use the existing tenant findings resource and current review consumers; do not add a new panel or queue +- Run all PHP, Artisan, and Pint commands through Sail + +## Narrow implementation order + +### 1. Tighten the finding-domain truth + +- Add canonical reason-key families to `apps/platform/app/Models/Finding.php` or an equivalent findings-local seam +- Add `apps/platform/app/Support/Findings/FindingOutcomeSemantics.php` to derive: + - terminal outcome key + - verification state + - report bucket + - operator-facing label +- Keep `apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php` aligned to the same bounded outcome semantics so `risk_accepted` stays separate from verified-clear meaning +- Keep reopen reasons in audit metadata; do not add a `reopened_reason` column + +### 2. Harden the workflow service + +- Update `apps/platform/app/Services/Findings/FindingWorkflowService.php` +- Replace free-form `validatedReason()` usage with canonical key validation +- Keep manual resolve as `remediated` and system-clear reasons as trusted verified-clear keys +- Widen the system-clear path narrowly enough to confirm already resolved findings without adding a new primary status +- Preserve or extend audit metadata for `resolved_reason`, `closed_reason`, `reopened_reason`, `system_origin`, and timestamps + +### 3. Align automation paths + +- Update these existing producers to use the canonical system-clear and reopen keys: + - `apps/platform/app/Services/Baselines/BaselineAutoCloseService.php` + - `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` + - `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` + - `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` +- Replace legacy or ad hoc reason strings in `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php` and `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php` in the same slice instead of introducing alias maps + +### 4. Update operator surfaces + +- Align `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php` copy and reason-policy metadata with the canonical findings vocabulary +- Update `apps/platform/app/Filament/Resources/FindingResource.php` +- Replace free-form resolve and close textareas with canonical selections plus optional note fields if needed +- Keep destructive-like actions confirmed with `->requiresConfirmation()` +- Update list filters, detail summaries, and terminal-outcome labels to use the shared semantics helper +- Preserve the existing open backlog defaults from Spec 111 + +### 5. Update review and reporting consumers + +- Update `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php` +- Update `apps/platform/app/Filament/Resources/TenantReviewResource.php` +- Reuse the shared semantics helper anywhere findings-derived summaries or projection buckets are rendered +- Do not create a second reporting taxonomy or reporting-only persistence layer + +### 6. Prove the change with focused tests + +- Extend existing findings service and UI tests first +- Add one focused reporting summary test only if no existing suite naturally owns that proof +- Keep coverage in feature suites; browser coverage is not needed for this slice + +## Validation commands + +Run after implementation: + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowServiceTest.php tests/Feature/Findings/FindingRecurrenceTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php +cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +## Review checklist + +- No new primary findings status was introduced +- `resolved_reason` and `closed_reason` are bounded canonical keys, not free-form reporting truth +- `reopened_reason` remains reviewable through audit metadata without adding new persistence +- `risk_accepted` still depends on exception validity and is not counted as verified clear +- `FindingResource`, `ReviewRegister`, and `TenantReviewResource` use one shared outcome semantics seam +- No compatibility aliases were kept for replaced reason keys unless a spec change explicitly required them \ No newline at end of file diff --git a/specs/231-finding-outcome-taxonomy/research.md b/specs/231-finding-outcome-taxonomy/research.md new file mode 100644 index 00000000..eb2a41da --- /dev/null +++ b/specs/231-finding-outcome-taxonomy/research.md @@ -0,0 +1,57 @@ +# Research: Finding Outcome Taxonomy & Verification Semantics + +## Decision 1: Preserve the primary findings status family and derive verification meaning + +- **Decision**: Keep the existing `Finding` lifecycle statuses (`new`, `triaged`, `in_progress`, `reopened`, `resolved`, `closed`, `risk_accepted`) and derive `Resolved pending verification` versus `Verified cleared` from bounded canonical reason keys plus existing audit history. +- **Rationale**: `App\Models\Finding` already defines open and terminal status sets, `FindingWorkflowService` and its tests assume that matrix, and the spec explicitly forbids a new primary status just to represent verification. The narrowest viable change is to make terminal meaning derive from bounded reason families instead of expanding the workflow state machine. +- **Alternatives considered**: + - Add a new `verified` status: rejected because it widens queue semantics, transition rules, and operator routing for a problem that can remain derived. + - Add a second outcome table or reporting-only register: rejected because there is no new independent source of truth or lifecycle to persist. + +## Decision 2: Reuse existing `resolved_reason` and `closed_reason` fields, and keep reopen reasons in audit metadata + +- **Decision**: Reuse the existing `resolved_reason` and `closed_reason` columns as canonical stable keys, and keep `reopened_reason` as structured audit metadata instead of adding a new `reopened_reason` column. +- **Rationale**: `FindingWorkflowService` already writes `resolved_reason`, `closed_reason`, `system_origin`, and `reopened_reason` into current record state and audit metadata. The service only needs to stop accepting arbitrary free-form text and instead validate against bounded key families. This keeps the implementation inside existing persistence truth and matches the spec's no-new-entity posture. +- **Alternatives considered**: + - Add a new JSON outcome payload on `findings`: rejected because it would create a second semantic store for data already represented by existing columns and audit events. + - Keep free-form textarea input as the primary meaning: rejected because list filters, review consumers, and audit readers cannot safely depend on prose. + +## Decision 3: Use one findings-local semantics helper rather than page-local mappings or a generic framework + +- **Decision**: Add one findings-local helper, such as `App\Support\Findings\FindingOutcomeSemantics`, to convert current finding truth into terminal-outcome labels, verification state, and report buckets. +- **Rationale**: Multiple readers already need the same meaning: `FindingResource`, `FindingRiskGovernanceResolver`, `ReviewRegister`, `TenantReviewResource`, and findings-derived summary or projection code. A single findings-local helper is justified because at least two real consumers already exist, while a generic governance-wide taxonomy engine would be broader than the current release truth. +- **Alternatives considered**: + - Keep logic inside `FindingResource` only: rejected because review and reporting consumers would immediately drift. + - Build a broad cross-domain governance taxonomy framework: rejected because the current scope is bounded to findings terminal outcomes. + +## Decision 4: Use canonical reason keys to make system-cleared outcomes row-visible, and keep audit history for path reconstruction + +- **Decision**: Treat canonical resolve keys as the row-level source for pending-versus-verified meaning. Manual resolve uses a bounded operator key such as `remediated`, while trusted system-clear reasons continue to use bounded system keys such as `no_longer_drifting`, `permission_granted`, `permission_removed_from_registry`, `role_assignment_removed`, and `ga_count_within_threshold`. +- **Rationale**: `resolveBySystem()` currently writes `system_origin` into audit metadata, but that audit-only flag is not enough for list filters or report buckets. Using canonical resolve keys as the current-row truth makes terminal meaning queryable without adding a new column, while the audit trail still records whether the final verified-clear state came from a direct system clear or a later confirmation after manual resolution. +- **Alternatives considered**: + - Add a persisted `verification_state` column: rejected because the same meaning can be derived from the current status plus canonical reason keys. + - Depend only on audit metadata for verified-clear meaning: rejected because list filters and read models should not need audit log reconstruction to classify current records. + +## Decision 5: Widen the system resolve path narrowly enough to confirm already resolved findings + +- **Decision**: Allow the system-clear path to update a finding that is already `resolved` when the purpose is to move it from operator-declared resolution to a trusted verified-clear reason. +- **Rationale**: `FindingWorkflowService::resolveBySystem()` currently only accepts open findings, which is too narrow for the spec requirement that a previously resolved finding may later be confirmed clear by trusted evidence. The narrowest fix is to widen the system path for this exact case instead of adding a new status or a second persistence field. +- **Alternatives considered**: + - Force a reopen-then-resolve cycle to represent verification: rejected because it would falsify the user-visible history and generate unnecessary workflow churn. + - Add a second workflow method dedicated to verification with separate persisted state: rejected because it adds more surface than the current release needs. + +## Decision 6: Keep `risk_accepted` separate from remediation and verification semantics + +- **Decision**: Preserve `risk_accepted` as its own terminal class governed by exception validity, not as a close reason and not as a verified-clear outcome. +- **Rationale**: `FindingRiskGovernanceResolver` already interprets `accepted_risk` and exception validity separately, and the spec requires Spec 154 semantics to remain authoritative. This means the outcome taxonomy should expose `risk_accepted` as a distinct terminal bucket while continuing to compute governance validity independently. +- **Alternatives considered**: + - Fold `risk_accepted` into generic closed outcomes: rejected because that would hide governance validity consequences. + - Treat valid accepted risk as verified clear: rejected because risk acceptance is an administrative governance decision, not proof of remediation. + +## Decision 7: Replace existing ad hoc keys in one pass instead of preserving aliases + +- **Decision**: Replace existing ad hoc or legacy-like reason strings inside current code, factories, and backfill jobs during implementation rather than preserving alias maps. +- **Rationale**: The repository is still pre-production, LEAN-001 explicitly rejects compatibility shims when there is no live production data, and current code already shows several ad hoc reason strings such as `duplicate`, `accepted_risk`, and `consolidated_duplicate`. The cleanest implementation is one canonical bounded family, updated everywhere in the same slice. +- **Alternatives considered**: + - Maintain alias translation tables for old and new reason keys: rejected because it adds avoidable compatibility machinery. + - Leave existing reasons mixed and normalize only in the UI: rejected because backend tests, report buckets, and audit semantics would remain ambiguous. \ No newline at end of file diff --git a/specs/231-finding-outcome-taxonomy/spec.md b/specs/231-finding-outcome-taxonomy/spec.md new file mode 100644 index 00000000..ce110d5f --- /dev/null +++ b/specs/231-finding-outcome-taxonomy/spec.md @@ -0,0 +1,276 @@ +# Feature Specification: Finding Outcome Taxonomy & Verification Semantics + +**Feature Branch**: `231-finding-outcome-taxonomy` +**Created**: 2026-04-22 +**Status**: Draft +**Input**: User description: "Finding Outcome Taxonomy & Verification Semantics" + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: Findings can currently end in materially different ways, but the product does not communicate those differences with one bounded, structured meaning. `Resolved`, `closed`, auto-cleared, false-positive, duplicate, and no-longer-applicable paths can all collapse into ambiguous operator or reporting language. +- **Today's failure**: An operator, reviewer, or auditor cannot reliably tell whether a finding was fixed by an operator, later verified as cleared by the system, closed as non-actionable, or still only waiting for confirmation. Filters, summaries, and review outputs therefore drift away from the real governance meaning. +- **User-visible improvement**: Findings surfaces show one honest terminal-outcome language: operators can choose bounded terminal reasons, lists and detail views distinguish `Resolved pending verification` from `Verified cleared`, and reporting can separate remediation outcomes from administrative closure outcomes. +- **Smallest enterprise-capable version**: Keep the existing findings lifecycle from Spec 111, add one bounded structured outcome taxonomy plus one bounded verification layer on top of existing findings, and apply that contract consistently to action forms, detail summaries, list filters, reopen reasons, audit copy, and downstream reporting consumers. +- **Explicit non-goals**: No comments or case-notes system, no attachments, no new findings queue, no external ticket handoff, no risk-exception redesign, no generic case-management engine, and no new primary lifecycle status family for findings. +- **Permanent complexity imported**: One bounded findings outcome taxonomy, one bounded verification-outcome vocabulary, one bounded reopen-reason family, focused action and filter updates on existing findings surfaces, and regression coverage for action, filter, audit, and reporting semantics. +- **Why now**: The roadmap's `Findings Workflow v2 / Execution Layer` already has ownership, inbox, intake, notifications, and hygiene slices specced. The remaining hardening point is exactly `resolved-versus-verified outcome semantics`, and downstream review and reporting quality depends on it. +- **Why not local**: A local wording fix in one modal or one report would still leave detail views, reopen behavior, filters, audit events, and review outputs speaking different terminal-outcome languages. +- **Approval class**: Core Enterprise +- **Red flags triggered**: One new structured reason family and one new verification interpretation layer. Scope remains acceptable because the feature reuses the existing finding lifecycle, existing exception validity semantics, and existing finding records instead of adding a new entity or queue. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitat: 1 | Produktnahe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant +- **Primary Routes**: + - `/admin/t/{tenant}/findings` as the existing tenant findings queue and filter surface + - `/admin/t/{tenant}/findings/{finding}` as the existing finding detail and transition surface + - Existing findings-derived review, summary, and export consumers that already aggregate terminal findings outcomes, without introducing a new primary route in this slice +- **Data Ownership**: + - Tenant-owned findings remain the only source of truth for workflow status, outcome meaning, verification meaning, and reopen semantics. + - Existing review, export, and governance summary consumers remain derived read models over finding and exception truth; no second outcome register or reporting-only persistence layer is introduced. + - Existing audit events remain the durable mutation trail for resolve, close, and reopen transitions. +- **RBAC**: + - Tenant membership is required for read or mutation behavior on tenant findings surfaces. + - Existing findings view permission gates read access. + - Existing findings transition or management permissions gate resolve, close, and reopen mutations. + - Existing review and export consumers must remain tenant-entitlement filtered; users must not learn terminal-outcome counts or labels for unauthorized tenants. + - Non-members and cross-tenant requests remain deny-as-not-found. In-scope users lacking the required mutation capability remain explicitly forbidden for protected actions. + +## 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 +- **Interaction class(es)**: status messaging, action-modal semantics, filters, reporting and export summaries, audit prose +- **Systems touched**: + - Existing findings lifecycle action surfaces on the tenant findings list and finding detail page + - Existing findings list filters and terminal-state presentation + - Existing findings-derived review, report, and export summaries + - Existing audit or history rendering for finding workflow transitions +- **Existing pattern(s) to extend**: + - Findings lifecycle contract from Spec 111 + - Risk-acceptance validity semantics from Spec 154 + - Operator-language and reason-translation foundations from Specs 156, 157, 161, and 214 +- **Shared contract / presenter / builder / renderer to reuse**: + - Reuse the existing findings workflow lifecycle as the primary status layer + - Reuse the existing findings list and detail surface families as the primary operator surfaces + - Reuse existing findings-derived summary and reporting consumers as downstream readers of the new outcome taxonomy rather than inventing a second summary path +- **Why the existing shared path is sufficient or insufficient**: The existing findings lifecycle is sufficient as the main status layer and should remain unchanged. It is insufficient on its own because status plus free-form reason text does not separate operator-resolved, system-verified, and non-remediation closure outcomes in a stable way. +- **Allowed deviation and why**: none. Read-only reporting consumers may compress the wording for stakeholder readability, but they must preserve the same taxonomy and bucket boundaries as the operator surfaces. +- **Consistency impact**: Resolve, close, and reopen labels; modal help text; default-visible terminal outcome summaries; filters; audit prose; and review or export buckets must all use the same canonical outcome language. +- **Review focus**: Reviewers must confirm that one structured taxonomy drives actions, detail summaries, filters, audit copy, and reporting rollups, and that no page-local synonym or free-text-only outcome path remains the primary source of meaning. + +## 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 resource table, grouped actions, filters, and existing shared findings primitives | Same findings workflow family as Spec 219, 221, 222, 224, and 225 | workflow status, terminal outcome, verification meaning, risk-governance validity | no | Existing queue surface only; no new queue | +| Finding detail and terminal action modals | yes | Native Filament record page, infolist or detail primitives, and existing action modals | Same findings workflow family as the tenant list | workflow status, terminal outcome, verification meaning, audit-facing summary | no | Existing detail surface only; no new detail shell | + +## 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 decides how an open finding should end and later scans whether terminal findings were resolved, verified, or administratively closed | Lifecycle status, severity, due state, owner, assignee, terminal outcome summary, and verification meaning for terminal rows | Full evidence, raw payloads, audit trail, and exception history after opening the finding | Primary because this is where operators scan and route work at backlog scale | Follows the findings workflow instead of forcing report-first reconstruction | Removes the need to open each terminal finding just to learn whether it was actually fixed, only claimed fixed, or closed as non-actionable | +| Finding detail and terminal action modals | Secondary Context Surface | A tenant operator confirms the correct terminal meaning for one finding before resolving, closing, or reopening it | Finding summary, current workflow state, current outcome summary, exception validity when relevant, and the exact action choice | Extended evidence, history, and full audit context | Secondary because it resolves one case after the list already identified the record | Preserves the single-finding workflow without inventing a separate governance case page | Avoids cross-page reconstruction when choosing between remediation, non-actionable closure, and reopen paths | + +## 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 apply a bounded terminal action | Finding | required | Structured `More` action group and grouped bulk actions | Existing dangerous actions remain grouped and confirmed where already required | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant context, severity, due state, workflow filters, terminal outcome filters | Findings / Finding | Whether a terminal finding is resolved pending verification, verified cleared, closed for a non-remediation reason, or governed accepted risk | none | +| Finding detail and terminal action modals | Record / Detail / Actions | View-first operational detail | Resolve, close, or reopen one finding with the correct meaning | Finding | N/A - detail surface | Structured existing header actions and bounded transition modals | Existing dangerous actions remain grouped and confirmed | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant breadcrumb, workflow status, severity, outcome summary, and risk-governance context | Findings / Finding | Why this finding is terminal right now and whether that terminal state is operator-declared, system-confirmed, or administratively closed | 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 findings to the correct terminal meaning and scan the existing backlog honestly | Workflow queue | Was this finding fixed, only marked fixed, system-confirmed clear, or closed as non-actionable? | Severity, lifecycle state, due state, owner, assignee, terminal outcome summary, and verification summary for terminal findings | Raw evidence, provider details, long audit history, and related run metadata | lifecycle, severity, terminal outcome, verification meaning, risk-governance validity | TenantPilot only | Open finding, Resolve, Close, Reopen | Risk accept remains dangerous and governed separately; existing dangerous transitions stay confirmed | +| Finding detail and terminal action modals | Tenant operator or tenant manager | Choose the correct terminal path for one finding and verify current terminal meaning | Detail/action surface | Am I resolving this issue, confirming it was later cleared, closing it as non-actionable, or reopening it because the issue persists? | Finding summary, current state, current terminal outcome summary, current exception validity when relevant, and the next transition choices | Raw evidence payloads, historical audit entries, and related governance context | lifecycle, terminal outcome, verification meaning, risk-governance validity | TenantPilot only | Resolve, Close, Reopen | Request exception remains governed by Spec 154 and stays separately confirmed where required | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: yes - one bounded findings outcome taxonomy and verification interpretation contract over existing finding records, action forms, filters, audit entries, and reporting consumers +- **New enum/state/reason family?**: yes - one bounded resolve-reason family, one bounded close-reason family, one bounded reopen-reason family, and one bounded verification-outcome vocabulary +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Terminal findings can currently look similar even when the underlying meaning is very different, which weakens operator trust and corrupts reporting quality. +- **Existing structure is insufficient because**: The existing lifecycle from Spec 111 tells whether a finding is open or terminal, but not whether a terminal finding was remediated, only declared remediated, system-confirmed clear, or closed as non-actionable. Free-form reason text cannot safely carry that distinction across filters, reports, and audit views. +- **Narrowest correct implementation**: Preserve the existing lifecycle, add structured bounded reasons and one verification layer, and apply them only to existing findings actions and readers. +- **Ownership cost**: Ongoing maintenance for the bounded reason vocabularies, audit and reporting mapping discipline, and focused regression coverage. +- **Alternative intentionally rejected**: Introducing a new primary findings status such as `verified`, or a generic case-management framework, was rejected because both would import broader workflow complexity than the actual operator problem requires. +- **Release truth**: Current-release truth. This feature makes the current findings workflow and reporting honest now instead of preparing a speculative later platform. + +### 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 proving burden is operator-visible and report-visible semantics on existing findings workflow surfaces. Focused feature coverage is sufficient to prove structured terminal-action behavior, verification or reopen behavior, filter rollups, audit copy, and downstream summary meaning without requiring browser or heavy-governance tests. +- **New or expanded test families**: Add focused finding transition tests for structured resolve, close, and reopen reasons; finding detail and list rendering tests for terminal-outcome summaries; filter and reporting summary tests for distinct terminal buckets; and audit-entry tests for structured outcome keys. +- **Fixture / helper cost impact**: Moderate. Tests need open, resolved, closed, reopened, and risk-accepted findings; trusted detection or verification inputs for confirm-clear and recurrence cases; valid and invalid exception coverage; and mixed tenant-visibility scenarios for report consumers. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: standard-native-filament +- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, plus explicit proof that open-backlog defaults remain unchanged, that verified-cleared and operator-resolved outcomes stay distinct, and that review or export buckets do not collapse non-remediation closures into remediation outcomes. +- **Reviewer handoff**: Reviewers must confirm that the feature does not create a new primary findings status, that free-form text is no longer the primary reporting key, that risk-accepted findings remain governed by exception validity instead of merging into verified-clear semantics, and that the same taxonomy appears in actions, list filters, detail summaries, audit prose, and downstream rollups. +- **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/FindingWorkflowServiceTest.php tests/Feature/Findings/FindingRecurrenceTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - End a finding with the correct terminal meaning (Priority: P1) + +As a tenant operator, I want bounded resolve and close reasons instead of free-form terminal outcomes, so that I can end a finding honestly and downstream reporting keeps the same meaning. + +**Why this priority**: This is the smallest operator-visible slice and the source of all later reporting quality. If the transition itself stays ambiguous, no summary or report can fix the semantics afterward. + +**Independent Test**: Can be fully tested by executing resolve and close transitions on open findings, then verifying that the workflow response, detail narrative, and terminal-outcome filters show the structured meaning without relying on free-form text. + +**Acceptance Scenarios**: + +1. **Given** an open finding, **When** an authorized operator resolves it as remediated work, **Then** the finding becomes terminal with the meaning `Resolved pending verification` rather than `Closed`. +2. **Given** an open finding, **When** an authorized operator closes it as `False positive`, **Then** the finding becomes `closed` and all operator surfaces show the non-remediation closure meaning rather than a remediation outcome. +3. **Given** an open finding, **When** an authorized operator closes it as `Duplicate` or `No longer applicable`, **Then** list filters and detail summaries use that canonical close reason instead of free-form prose. + +--- + +### User Story 2 - See whether a terminal finding was actually verified clear (Priority: P1) + +As a reviewer or accountable owner, I want to distinguish operator-resolved findings from system-verified cleared findings, so that I can trust whether the issue was only declared fixed or later confirmed gone. + +**Why this priority**: This is the roadmap's explicit unresolved gap. Without it, the product cannot honestly answer whether terminal findings are truly cleared or only awaiting verification. + +**Independent Test**: Can be fully tested by resolving a finding, then simulating a later trusted verification-clear event and a later recurrence event, and verifying that one path becomes verified clear while the other reopens with a structured reason. + +**Acceptance Scenarios**: + +1. **Given** a finding is already `resolved`, **When** later trusted system evidence confirms that the issue is no longer present, **Then** the finding remains terminal and surfaces as `Verified cleared` rather than plain unresolved `resolved` wording. +2. **Given** a finding is already `resolved`, **When** later trusted system evidence shows the issue is present again, **Then** the finding reopens with a structured reopen reason and does not remain reported as verified clear. +3. **Given** a finding was system-cleared without an operator transition, **When** the finding is shown on detail or reporting surfaces, **Then** the product distinguishes that system-confirmed terminal outcome from a manually declared resolution. + +--- + +### User Story 3 - Filter and summarize terminal outcomes consistently (Priority: P2) + +As a reviewer or report consumer, I want terminal findings filters and summaries to use one canonical taxonomy, so that review packs, exports, and list filters separate remediation outcomes from administrative closure outcomes. + +**Why this priority**: Once transitions are honest, summaries must preserve that meaning. This is the smallest slice that makes the taxonomy operationally useful beyond the single record. + +**Independent Test**: Can be fully tested by seeding mixed terminal findings and verifying that list filters and reporting consumers keep `Resolved pending verification`, `Verified cleared`, `Closed as false positive`, `Closed as duplicate`, `Closed as no longer applicable`, and governed `Risk accepted` in distinct buckets. + +**Acceptance Scenarios**: + +1. **Given** a tenant has terminal findings across multiple outcome meanings, **When** an operator filters or summarizes terminal findings, **Then** the buckets remain distinct instead of collapsing into one generic `resolved` or `closed` group. +2. **Given** a report or review summary consumes findings outcomes, **When** it renders terminal findings, **Then** operator-resolved pending verification and verified-cleared findings are counted separately. +3. **Given** an operator opens the default open findings queue, **When** no terminal-outcome filter is intentionally applied, **Then** the existing open-backlog behavior from Spec 111 remains unchanged. + +### Edge Cases + +- A finding may be auto-cleared by trusted system evidence without a prior manual resolve action; that path must not be reported as operator-resolved. +- A finding in `risk_accepted` status without a currently valid governing exception must remain a separate governance-validity problem and must not be counted as verified clear. +- A manually reopened `closed` or `risk_accepted` finding must record an explicit reopen reason and must not inherit any prior verified-clear meaning. +- Domains that do not yet have trustworthy system verification signals must remain `Resolved pending verification` until such a signal exists; the product must not imply silent verification. +- Free-form notes may still exist as secondary context, but filters and reporting must not depend on note wording to determine terminal meaning. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature introduces no Microsoft Graph calls, no new long-running job, no new scheduler, and no new `OperationRun`. It changes how existing finding transitions, findings filters, detail summaries, audit entries, and findings-derived reporting consumers interpret terminal outcomes. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces new bounded reason families and one bounded verification vocabulary because the current product truth cannot honestly separate operator-declared resolution from system-confirmed clearance or non-remediation closure. It reuses existing finding records and existing lifecycle statuses instead of adding a new entity or status family. + +**Constitution alignment (XCUT-001):** The feature is cross-cutting within the findings workflow family. It must extend one shared outcome taxonomy across actions, detail, filters, audit prose, and reporting consumers rather than allowing each surface to invent local wording. + +**Constitution alignment (TEST-GOV-001):** Focused feature tests are the narrowest sufficient proof. The tests must prove transition semantics, verification or reopen behavior, filter and reporting buckets, and audit-entry meaning. No browser or heavy-governance lane is justified. + +**Constitution alignment (RBAC-UX):** The feature operates on tenant findings surfaces and downstream tenant-entitlement-filtered reporting consumers. Existing tenant-safe 404 versus 403 behavior remains unchanged. Non-members and cross-tenant viewers receive `404`. In-scope users lacking the relevant finding transition capability receive `403` for protected mutations. Reporting consumers must not expose terminal-outcome counts or labels for unauthorized tenants. + +**Constitution alignment (BADGE-001):** Findings workflow status remains the primary centralized status family. Any added terminal-outcome or verification emphasis must reuse centralized presentation rules and must not introduce page-local color semantics. + +**Constitution alignment (UI-FIL-001):** The feature must use existing Filament table filters, grouped actions, detail surfaces, and action modals on the tenant findings resource. No custom badge markup, custom workflow shell, or page-local status component may be introduced. + +**Constitution alignment (UI-NAMING-001):** The canonical operator-facing vocabulary is `Resolve`, `Resolved pending verification`, `Verified cleared`, `Close`, `False positive`, `Duplicate`, `No longer applicable`, and `Reopen`. The term `risk accepted` remains governed by Spec 154 and must not be used as a synonym for verified clearance or closure. + +**Constitution alignment (DECIDE-001):** The tenant findings list remains the primary decision surface for scanning work and understanding terminal meaning at backlog scale. Finding detail remains the focused single-record decision context for choosing resolve, close, or reopen. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / HDR-001):** This feature changes the meaning and form content of existing actions only. It does not add a second findings queue, a second detail page, or a second inspect model. Row click remains the primary inspect affordance on the list. Existing dangerous actions stay grouped and confirmed where already required. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from status plus free-form text is insufficient because it cannot safely express terminal outcome meaning across operator surfaces and reports. This feature adds one bounded interpretation layer and must prove business consequences rather than only raw field storage. + +### Functional Requirements + +- **FR-001**: The system MUST preserve the findings lifecycle statuses from Spec 111. This feature MUST NOT introduce a new primary workflow status solely to represent verified clearance. +- **FR-002**: The system MUST define one bounded findings outcome taxonomy that distinguishes at minimum: operator-resolved pending verification, system-verified cleared, and closed non-remediation outcomes. +- **FR-003**: The single-record and bulk `Resolve` actions MUST require a structured resolve reason from a canonical bounded list. Optional explanatory notes MAY remain secondary context but MUST NOT be the primary reporting key. +- **FR-004**: The single-record and bulk `Close` actions MUST require a structured close reason from a canonical bounded list that includes at minimum `False positive`, `Duplicate`, and `No longer applicable`. +- **FR-005**: The system MUST define a bounded reopen-reason family for manual and automatic reopen paths. It MUST distinguish at minimum recurrence after prior resolution from manual reassessment or verification failure. +- **FR-006**: A finding in `resolved` status MUST surface by default as `Resolved pending verification` until later trusted evidence confirms the issue is actually clear. +- **FR-007**: When later trusted system evidence confirms that a previously resolved issue is no longer present, the system MUST surface that finding as `Verified cleared` without requiring a new primary findings status. +- **FR-008**: When later trusted system evidence shows that a previously resolved issue persists or recurs, the system MUST reopen the existing finding with a structured reopen reason and MUST remove any verified-clear classification. +- **FR-009**: System-cleared findings that become terminal through trusted automation or detection logic MUST remain distinguishable from manually resolved findings on detail, filters, and reporting consumers. +- **FR-010**: `Closed` findings MUST remain semantically separate from remediation-complete outcomes. They MUST NOT be counted or presented as verified clear or remediated unless a future explicit feature changes that rule. +- **FR-011**: Findings in `risk_accepted` status MUST remain governed by exception validity semantics from Spec 154 and MUST NOT collapse into resolved, verified-cleared, or closed-non-remediation buckets. +- **FR-012**: Tenant findings list and detail surfaces MUST show default-visible terminal outcome summaries whenever a finding is not open. Operators MUST NOT need to inspect raw audit payloads to learn the primary terminal meaning. +- **FR-013**: Terminal findings filters MUST allow operators to distinguish at minimum `Resolved pending verification`, `Verified cleared`, `False positive`, `Duplicate`, `No longer applicable`, and `Risk accepted` when those outcomes are present in scope. +- **FR-014**: Existing open-backlog defaults for the tenant findings list, `My Findings`, and intake-oriented workflow surfaces MUST remain based on the open status set from Spec 111 and MUST NOT be widened or redefined by terminal-outcome semantics. +- **FR-015**: Findings-derived reporting, review, and export consumers that summarize findings outcomes MUST consume the canonical taxonomy instead of free-form reason text or local labels. +- **FR-016**: Reporting and review consumers MUST count operator-resolved pending verification separately from verified-cleared findings whenever both are present. +- **FR-017**: Audit or history entries for resolve, close, and reopen transitions MUST record the structured outcome key and readable operator copy so later reviewers can reconstruct terminal meaning without parsing free-form prose alone. +- **FR-018**: Existing manual reopen paths from `resolved`, `closed`, and `risk_accepted` MUST continue to exist per Spec 111, but they MUST now require an explicit reopen reason that makes the cause of reopening reviewable. +- **FR-019**: This feature MUST NOT introduce comments, attachments, a second workflow state store, or a new canonical findings queue. +- **FR-020**: This feature MUST NOT redefine the exception approval or validity model from Spec 154. It only consumes that validity model to keep `risk_accepted` semantically separate from other terminal outcomes. + +## 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 and grouped bulk actions | `/admin/t/{tenant}/findings` | No new header actions; existing filters gain terminal-outcome semantics | Full-row open into finding detail | Existing primary inspect behavior only; terminal actions remain inside the structured existing row or grouped action path | Existing grouped `Resolve`, `Close`, and `Reopen` flows now require structured reasons; other grouped actions remain unchanged and out of scope | Existing empty-state behavior remains; no new CTA required in this slice | n/a | n/a | Yes for resolve, close, and reopen transitions through existing audit paths | Action Surface Contract remains satisfied. No new queue, no second inspect affordance, no empty action groups, and no page-local action family introduced. | +| Finding detail and terminal action modals | `/admin/t/{tenant}/findings/{finding}` | Existing detail actions remain; updated actions in scope are `Resolve`, `Close`, and `Reopen` | Detail surface | Existing bounded detail actions only | n/a | n/a | Existing `Resolve`, `Close`, and `Reopen` action modals now require structured reasons and show canonical terminology | n/a | Yes for resolve, close, and reopen transitions through existing audit paths | UI-FIL-001 satisfied through existing native detail and action-modal primitives. No exemption needed. | + +### Key Entities *(include if feature involves data)* + +- **Finding terminal outcome**: The bounded operator-facing meaning of how a finding became terminal, distinct from the primary workflow status. +- **Verification outcome**: A bounded distinction between operator-declared resolution and later trusted system-confirmed clearance. +- **Reopen reason**: The bounded explanation for why a previously terminal finding re-entered the open workflow. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In acceptance review, an operator can identify from the tenant findings list or detail page whether a terminal finding is `Resolved pending verification`, `Verified cleared`, or closed for a non-remediation reason without opening raw audit detail. +- **SC-002**: 100% of covered transition tests show that resolve, close, and reopen actions persist structured outcome semantics and do not rely on free-form text as the primary meaning. +- **SC-003**: 100% of covered filter and reporting tests keep `Resolved pending verification`, `Verified cleared`, `False positive`, `Duplicate`, `No longer applicable`, and governed `Risk accepted` in distinct outcome buckets when present. +- **SC-004**: 100% of covered queue tests show that the default open findings backlog remains unchanged and excludes terminal findings regardless of their terminal-outcome bucket. + +## Assumptions + +- Existing finding records and audit paths can carry the structured outcome metadata needed by this slice without introducing a new top-level entity. +- The first release limits `Verified cleared` semantics to finding families where the product already has trustworthy system evidence for clearance or recurrence. +- Free-form explanatory text may remain as optional secondary context, but it is not the canonical key for filters, summaries, or reports. +- Risk-acceptance validity continues to derive from the exception workflow defined in Spec 154. + +## Non-Goals + +- Introduce a new primary findings status such as `verified` or `confirmed_cleared`. +- Add comments, decision logs, attachments, or case-note collaboration. +- Add external ticket handoff, team workboards, or a new personal queue. +- Redesign risk-acceptance approvals, expiry, or revocation semantics. +- Build a generic evidence-attestation engine or universal governance case framework. + +## Dependencies + +- Spec 111 remains the source of truth for the core findings lifecycle, open-status defaults, and allowed reopen paths. +- Spec 154 remains the source of truth for exception validity and governed `risk_accepted` semantics. +- Spec 155 remains the downstream review-layer consumer that should inherit this taxonomy instead of inventing local terminal buckets. +- Specs 156, 157, 161, and 214 remain the semantic foundation for operator-safe language and explanation patterns across outcome-bearing surfaces. \ No newline at end of file diff --git a/specs/231-finding-outcome-taxonomy/tasks.md b/specs/231-finding-outcome-taxonomy/tasks.md new file mode 100644 index 00000000..ff93f8f5 --- /dev/null +++ b/specs/231-finding-outcome-taxonomy/tasks.md @@ -0,0 +1,229 @@ +# Tasks: Finding Outcome Taxonomy & Verification Semantics + +**Input**: Design documents from `/specs/231-finding-outcome-taxonomy/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/finding-outcome-taxonomy.logical.openapi.yaml`, `quickstart.md`, `checklists/requirements.md` + +**Tests**: Required. This feature changes runtime behavior in the tenant findings workflow, terminal-outcome presentation, and findings-derived review/report buckets, so Pest coverage must be added or updated in `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`, `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php`, `apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php`, `apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php`, and `apps/platform/tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`. +**Operations**: No new `OperationRun` is introduced. Existing `operation_run_id` provenance remains in scope only for trusted system clear and system reopen audit context; no new monitoring surface, queued notification surface, or run lifecycle contract may be introduced. +**RBAC**: This feature stays on the tenant `/admin/t/{tenant}/findings` plane plus existing tenant-scoped review consumers. The implementation must preserve non-member or cross-tenant `404`, in-scope missing-capability `403`, current server-side authorization in `FindingWorkflowService`, and existing tenant-safe review visibility semantics. +**UI / Surface Guardrails**: The existing tenant findings list, finding detail surface, and findings-derived review readers remain the only changed surfaces. This is a `standard-native-filament` slice with `review-mandatory` treatment; no new queue, no second detail shell, and no page-local outcome language may be introduced. +**Filament UI Action Surfaces**: `FindingResource` remains the only mutated Filament action surface, and `ReviewRegister` plus `TenantReviewResource` remain read-only consumers of the shared outcome semantics. No new page family, no second inspect model, no new global search entry point, and no empty action groups may be introduced. +**Badges**: Existing `BadgeCatalog` and `BadgeRenderer` semantics remain authoritative. Any terminal-outcome emphasis must extend shared finding presentation rules instead of adding page-local badge or color mappings. + +**Organization**: Tasks are grouped by user story so each slice remains independently testable once the shared outcome seam exists. Recommended delivery order is `US1 -> US2 -> US3` because manual canonical reasons must stabilize before trusted system-clear semantics and downstream reporting buckets can converge. + +## 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 (Focused Findings Outcome Test Surfaces) + +**Purpose**: Prepare the narrow regression surfaces that will prove manual terminal reasons, trusted verification, and findings-derived report buckets. + +- [X] T001 [P] Extend the canonical workflow transition test scaffold in `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php` +- [X] T002 [P] Extend the trusted recurrence and verification-failure scaffold in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php` +- [X] T003 [P] Extend the terminal-outcome filter scaffold in `apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php` +- [X] T004 [P] Extend the findings detail presentation scaffold in `apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php` +- [X] T005 [P] Create the findings-derived summary bucket scaffold in `apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php` +- [X] T006 [P] Extend the accepted-risk projection scaffold in `apps/platform/tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php` + +**Checkpoint**: Focused findings workflow, presentation, and reporting test surfaces are ready for implementation work. + +--- + +## Phase 2: Foundational (Blocking Canonical Outcome Seam) + +**Purpose**: Establish the canonical outcome seam and pre-production reason-key normalization before any user story implementation begins. + +**CRITICAL**: No user story work should begin until this phase is complete. + +- [X] T007 Implement canonical resolve, close, and reopen reason-key families in `apps/platform/app/Models/Finding.php` +- [X] T008 Create the shared derived terminal-outcome mapper in `apps/platform/app/Support/Findings/FindingOutcomeSemantics.php` +- [X] T009 Update canonical historical-context interpretation in `apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php` +- [X] T010 Normalize canonical finding outcome fixture states in `apps/platform/database/factories/FindingFactory.php` +- [X] T011 Replace pre-production legacy reason keys in `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php` +- [X] T012 Replace workspace-run legacy reason keys in `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php` +- [X] T013 Add baseline canonical-outcome and audit-metadata contract assertions in `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php` + +**Checkpoint**: The canonical outcome seam exists, historical context is aligned to canonical keys, and all supporting fixtures and backfill paths use one pre-production key family. + +--- + +## Phase 3: User Story 1 - End A Finding With The Correct Terminal Meaning (Priority: P1) 🎯 + +**Goal**: Give operators bounded resolve and close reasons so manual terminal outcomes are explicit instead of prose-driven. + +**Independent Test**: Resolve and close open findings through the existing actions, then verify the workflow response, detail narrative, and terminal-outcome filters show `Resolved pending verification` or the correct administrative closure outcome without depending on free-form text. + +### Tests for User Story 1 + +- [X] T014 [P] [US1] Add canonical manual resolve, close, risk-accept, and reopen validation plus audit-entry assertions in `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php` +- [X] T015 [P] [US1] Add manual terminal-outcome detail assertions in `apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php` +- [X] T016 [P] [US1] Add manual terminal-outcome filter assertions in `apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php` + +### Implementation for User Story 1 + +- [X] T017 [US1] Enforce canonical manual resolve, close, risk-accept, and reopen reason keys plus structured audit outcome copy in `apps/platform/app/Services/Findings/FindingWorkflowService.php` +- [X] T018 [US1] Align close-action labels, modal copy, and field contract in `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php` +- [X] T019 [US1] Replace single-record resolve and close inputs with canonical selections in `apps/platform/app/Filament/Resources/FindingResource.php` +- [X] T020 [US1] Replace bulk resolve and close inputs plus completion copy with canonical selections in `apps/platform/app/Filament/Resources/FindingResource.php` +- [X] T021 [US1] Surface manual terminal-outcome summaries on the finding detail surface in `apps/platform/app/Filament/Resources/FindingResource.php` + +**Checkpoint**: User Story 1 is independently functional and operators can end findings with bounded manual terminal meanings. + +--- + +## Phase 4: User Story 2 - See Whether A Terminal Finding Was Actually Verified Clear (Priority: P1) + +**Goal**: Distinguish operator-declared resolution from later trusted verified-clear outcomes and structured recurrence reopen paths. + +**Independent Test**: Resolve a finding manually, then apply trusted system clear and trusted recurrence paths and verify the result becomes `Verified cleared` or reopens with a structured reason without adding a new primary status. + +### Tests for User Story 2 + +- [X] T022 [P] [US2] Add verified-clear transition and audit assertions for direct and post-manual system clears in `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php` +- [X] T023 [P] [US2] Add recurrence and verification-failure reopen plus audit assertions in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php` +- [X] T024 [P] [US2] Add verified-cleared detail presentation assertions in `apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php` + +### Implementation for User Story 2 + +- [X] T025 [US2] Widen trusted system clear and reopen handling for verified-clear semantics and audit context in `apps/platform/app/Services/Findings/FindingWorkflowService.php` +- [X] T026 [US2] Normalize baseline auto-close system-clear reason keys in `apps/platform/app/Services/Baselines/BaselineAutoCloseService.php` +- [X] T027 [US2] Normalize Entra admin-role system clear and reopen reason keys in `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` +- [X] T028 [US2] Normalize permission-posture system clear and reopen reason keys in `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` +- [X] T029 [US2] Align recurrence-driven system reopen keys in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` +- [X] T030 [US2] Surface verified-cleared summaries and historical context on the finding resource in `apps/platform/app/Filament/Resources/FindingResource.php` + +**Checkpoint**: User Story 2 is independently functional and terminal findings can now distinguish manual resolution from trusted verified clearance. + +--- + +## Phase 5: User Story 3 - Filter And Summarize Terminal Outcomes Consistently (Priority: P2) + +**Goal**: Apply one canonical taxonomy across queue filters, review readers, and findings-derived reporting buckets. + +**Independent Test**: Seed mixed terminal outcomes and verify the queue filters, default open backlog, review views, executive-pack export cues, and findings-derived summaries keep `Resolved pending verification`, `Verified cleared`, administrative closures, and `Risk accepted` in distinct buckets. + +### Tests for User Story 3 + +- [X] T031 [P] [US3] Add queue filter coverage for all canonical manual and verified terminal buckets plus default open-backlog regression assertions in `apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php` +- [X] T032 [P] [US3] Add findings-derived review, reporting, and export-summary bucket coverage in `apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php` +- [X] T033 [P] [US3] Add accepted-risk governance-bucket separation coverage in `apps/platform/tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php` + +### Implementation for User Story 3 + +- [X] T034 [US3] Implement terminal-outcome filters and default-visible queue summaries while preserving existing open-backlog defaults in `apps/platform/app/Filament/Resources/FindingResource.php` +- [X] T035 [US3] Reuse shared outcome semantics in review register findings summaries in `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php` +- [X] T036 [US3] Reuse shared outcome semantics in tenant review outcome presentation and executive-pack export cues in `apps/platform/app/Filament/Resources/TenantReviewResource.php` + +**Checkpoint**: User Story 3 is independently functional and downstream readers no longer invent their own terminal-outcome taxonomy. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finish copy review, formatting, and the narrow proving workflow for the full feature. + +- [X] T037 Review operator-facing outcome labels and modal helper copy in `apps/platform/app/Filament/Resources/FindingResource.php` +- [X] T038 Run formatting for the touched findings outcome files referenced in `specs/231-finding-outcome-taxonomy/quickstart.md` with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- [X] T039 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowServiceTest.php tests/Feature/Findings/FindingRecurrenceTest.php` and `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php` for `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`, `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php`, `apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php`, `apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php`, and `apps/platform/tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: Starts immediately and prepares the focused test surfaces. +- **Foundational (Phase 2)**: Depends on Setup and blocks all user story work until the canonical outcome seam and pre-production key normalization are in place. +- **User Story 1 (Phase 3)**: Depends on Foundational completion and is the first shippable behavior slice. +- **User Story 2 (Phase 4)**: Depends on User Story 1 because trusted verification semantics build on the manual canonical reason contract. +- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because queue filters and review/report buckets must consume the settled shared taxonomy. +- **Polish (Phase 6)**: Depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1**: No dependencies beyond Foundational. +- **US2**: Requires the canonical manual reason contract from US1. +- **US3**: Requires the settled manual and trusted verification semantics from US1 and US2. + +### Within Each User Story + +- Write the story tests first and confirm they fail before implementation is considered complete. +- Keep `apps/platform/app/Support/Findings/FindingOutcomeSemantics.php` authoritative for derived terminal meaning instead of duplicating page-local mappings. +- Finish story-level verification before moving to the next priority slice. + +### Parallel Opportunities + +- `T001` through `T006` can run in parallel during Setup. +- `T014`, `T015`, and `T016` can run in parallel for User Story 1. +- `T022`, `T023`, and `T024` can run in parallel for User Story 2. +- `T031`, `T032`, and `T033` can run in parallel for User Story 3. + +--- + +## Parallel Example: User Story 1 + +```bash +# User Story 1 tests in parallel +T014 apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php +T015 apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php +T016 apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php +``` + +## Parallel Example: User Story 2 + +```bash +# User Story 2 tests in parallel +T022 apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php +T023 apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php +T024 apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php +``` + +## Parallel Example: User Story 3 + +```bash +# User Story 3 tests in parallel +T031 apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php +T032 apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php +T033 apps/platform/tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php +``` + +--- + +## Implementation Strategy + +### MVP First + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Complete Phase 4: User Story 2. +5. Validate the feature against the focused US1 and US2 tests before widening to downstream review/report consumers. + +### Incremental Delivery + +1. Ship US1 to replace free-form terminal reasons with bounded manual outcomes. +2. Add US2 to close the roadmap gap between operator-declared resolution and trusted verified clearance. +3. Add US3 to converge queue filters and findings-derived review/report buckets on the same taxonomy. +4. Finish with copy review, formatting, and the focused validation pack. + +### Parallel Team Strategy + +1. One contributor can prepare the shared helper and canonical key normalization while another extends the focused test scaffolds. +2. After Foundational work lands, manual findings actions and trusted system-clear producer paths can progress in parallel across different files. +3. Review-register and tenant-review consumers can be updated in parallel once the settled outcome seam and queue filters are complete. + +--- + +## 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 4 because the feature promise is incomplete without the verified-clear slice. +- All implementation tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and exact file paths. -- 2.45.2 From 2bf53f6337a59042785529f83579ee736cd03e44 Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 23 Apr 2026 13:09:53 +0000 Subject: [PATCH 05/36] Enforce operation run link contract (#268) ## Summary - enforce shared operation run link generation across admin and system surfaces - add guard coverage to block new raw operation route bypasses outside explicit exceptions - harden Filament theme asset resolution so stale or wrong-stack hot files fall back to built assets ## Testing - export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent - export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/CanonicalViewRunLinksTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Filament/InventoryCoverageRunContinuityTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php tests/Feature/078/RelatedLinksOnDetailTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php tests/Unit/Filament/PanelThemeAssetTest.php Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/268 --- .github/agents/copilot-instructions.md | 4 +- .../app/Filament/Pages/InventoryCoverage.php | 12 +- .../TenantlessOperationRunViewer.php | 6 +- .../Resources/InventoryItemResource.php | 9 +- .../Filament/Resources/ReviewPackResource.php | 17 +- .../Tenant/RecentOperationsSummary.php | 4 +- .../app/Support/Filament/PanelThemeAsset.php | 72 +++- .../Navigation/RelatedNavigationResolver.php | 2 +- .../app/Support/OperationRunLinks.php | 5 + .../Feature/078/RelatedLinksOnDetailTest.php | 3 +- ...onicalOperationViewerDeepLinkTrustTest.php | 28 ++ .../InventoryCoverageRunContinuityTest.php | 33 +- .../RecentOperationsSummaryWidgetTest.php | 4 +- .../OperationRunLinkContractGuardTest.php | 160 ++++++++ .../OpsUx/CanonicalViewRunLinksTest.php | 37 ++ .../ReviewPack/ReviewPackResourceTest.php | 6 + .../Spec113/AuthorizationSemanticsTest.php | 42 ++ .../Unit/Filament/PanelThemeAssetTest.php | 23 ++ .../checklists/requirements.md | 36 ++ ...ion-run-link-contract.logical.openapi.yaml | 380 ++++++++++++++++++ .../data-model.md | 199 +++++++++ specs/232-operation-run-link-contract/plan.md | 230 +++++++++++ .../quickstart.md | 97 +++++ .../research.md | 67 +++ specs/232-operation-run-link-contract/spec.md | 302 ++++++++++++++ .../232-operation-run-link-contract/tasks.md | 228 +++++++++++ 26 files changed, 1974 insertions(+), 32 deletions(-) create mode 100644 apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php create mode 100644 specs/232-operation-run-link-contract/checklists/requirements.md create mode 100644 specs/232-operation-run-link-contract/contracts/operation-run-link-contract.logical.openapi.yaml create mode 100644 specs/232-operation-run-link-contract/data-model.md create mode 100644 specs/232-operation-run-link-contract/plan.md create mode 100644 specs/232-operation-run-link-contract/quickstart.md create mode 100644 specs/232-operation-run-link-contract/research.md create mode 100644 specs/232-operation-run-link-contract/spec.md create mode 100644 specs/232-operation-run-link-contract/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 07db2c88..96e9b297 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -240,6 +240,8 @@ ## Active Technologies - Filesystem only (`specs/226-astrodeck-inventory-planning/*`) (226-astrodeck-inventory-planning) - PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger` (231-finding-outcome-taxonomy) - PostgreSQL via existing `findings`, `finding_exceptions`, `tenant_reviews`, `stored_reports`, and audit-log tables; no schema changes planned (231-finding-outcome-taxonomy) +- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers (232-operation-run-link-contract) +- PostgreSQL-backed existing `operation_runs`, `tenants`, and `workspaces` records plus current session-backed canonical navigation state; no new persistence (232-operation-run-link-contract) - PHP 8.4.15 (feat/005-bulk-operations) @@ -274,9 +276,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 232-operation-run-link-contract: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers - 231-finding-outcome-taxonomy: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger` - 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 ### Pre-production compatibility check diff --git a/apps/platform/app/Filament/Pages/InventoryCoverage.php b/apps/platform/app/Filament/Pages/InventoryCoverage.php index bfdc6df4..537cb3a0 100644 --- a/apps/platform/app/Filament/Pages/InventoryCoverage.php +++ b/apps/platform/app/Filament/Pages/InventoryCoverage.php @@ -20,6 +20,7 @@ use App\Support\Badges\TagBadgeDomain; use App\Support\Inventory\TenantCoverageTruth; use App\Support\Inventory\TenantCoverageTruthResolver; +use App\Support\OperationRunLinks; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; @@ -535,7 +536,7 @@ public function basisRunSummary(): array : 'The coverage basis is current, but your role cannot open the cited run detail.', 'badgeLabel' => $badge->label, 'badgeColor' => $badge->color, - 'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null, + 'runUrl' => $canViewRun ? OperationRunLinks::view($truth->basisRun, $tenant) : null, 'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null, 'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant), ]; @@ -560,13 +561,6 @@ protected function coverageTruth(): ?TenantCoverageTruth private function inventorySyncHistoryUrl(Tenant $tenant): string { - return route('admin.operations.index', [ - 'tenant_id' => (int) $tenant->getKey(), - 'tableFilters' => [ - 'type' => [ - 'value' => 'inventory_sync', - ], - ], - ]); + return OperationRunLinks::index($tenant, operationType: 'inventory_sync'); } } diff --git a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index 71b4b450..1223aa74 100644 --- a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -110,14 +110,14 @@ protected function getHeaderActions(): array $actions[] = Action::make('operate_hub_back_to_operations') ->label('Back to Operations') ->color('gray') - ->url(fn (): string => route('admin.operations.index')); + ->url(fn (): string => OperationRunLinks::index()); } if ($activeTenant instanceof Tenant) { $actions[] = Action::make('operate_hub_show_all_operations') ->label('Show all operations') ->color('gray') - ->url(fn (): string => route('admin.operations.index')); + ->url(fn (): string => OperationRunLinks::index()); } $actions[] = Action::make('refresh') @@ -126,7 +126,7 @@ protected function getHeaderActions(): array ->color('primary') ->url(fn (): string => isset($this->run) ? OperationRunLinks::tenantlessView($this->run, $navigationContext) - : route('admin.operations.index')); + : OperationRunLinks::index()); if (! isset($this->run)) { return $actions; diff --git a/apps/platform/app/Filament/Resources/InventoryItemResource.php b/apps/platform/app/Filament/Resources/InventoryItemResource.php index 736d7dce..db81bca3 100644 --- a/apps/platform/app/Filament/Resources/InventoryItemResource.php +++ b/apps/platform/app/Filament/Resources/InventoryItemResource.php @@ -17,6 +17,7 @@ use App\Support\Badges\TagBadgeRenderer; use App\Support\Filament\FilterOptionCatalog; use App\Support\Inventory\InventoryPolicyTypeMeta; +use App\Support\OperationRunLinks; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; @@ -148,7 +149,13 @@ public static function infolist(Schema $schema): Schema return null; } - return route('admin.operations.view', ['run' => (int) $record->last_seen_operation_run_id]); + $tenant = $record->tenant; + + if ($tenant instanceof Tenant) { + return OperationRunLinks::view((int) $record->last_seen_operation_run_id, $tenant); + } + + return OperationRunLinks::tenantlessView((int) $record->last_seen_operation_run_id); }) ->openUrlInNewTab(), TextEntry::make('support_restore') diff --git a/apps/platform/app/Filament/Resources/ReviewPackResource.php b/apps/platform/app/Filament/Resources/ReviewPackResource.php index d69b4e81..f1075e70 100644 --- a/apps/platform/app/Filament/Resources/ReviewPackResource.php +++ b/apps/platform/app/Filament/Resources/ReviewPackResource.php @@ -13,6 +13,7 @@ use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; +use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\Rbac\UiEnforcement; use App\Support\ReviewPackStatus; @@ -199,9 +200,19 @@ public static function infolist(Schema $schema): Schema ->placeholder('—'), TextEntry::make('operationRun.id') ->label('Operation') - ->url(fn (ReviewPack $record): ?string => $record->operation_run_id - ? route('admin.operations.view', ['run' => (int) $record->operation_run_id]) - : null) + ->url(function (ReviewPack $record): ?string { + if (! $record->operation_run_id) { + return null; + } + + $tenant = $record->tenant; + + if ($tenant instanceof Tenant) { + return OperationRunLinks::view((int) $record->operation_run_id, $tenant); + } + + return OperationRunLinks::tenantlessView((int) $record->operation_run_id); + }) ->openUrlInNewTab() ->placeholder('—'), TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'), diff --git a/apps/platform/app/Filament/Widgets/Tenant/RecentOperationsSummary.php b/apps/platform/app/Filament/Widgets/Tenant/RecentOperationsSummary.php index 18a8aa2e..5141a729 100644 --- a/apps/platform/app/Filament/Widgets/Tenant/RecentOperationsSummary.php +++ b/apps/platform/app/Filament/Widgets/Tenant/RecentOperationsSummary.php @@ -41,7 +41,7 @@ protected function getViewData(): array return [ 'tenant' => null, 'runs' => collect(), - 'operationsIndexUrl' => route('admin.operations.index'), + 'operationsIndexUrl' => OperationRunLinks::index(), 'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(), 'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(), ]; @@ -68,7 +68,7 @@ protected function getViewData(): array return [ 'tenant' => $tenant, 'runs' => $runs, - 'operationsIndexUrl' => route('admin.operations.index'), + 'operationsIndexUrl' => OperationRunLinks::index($tenant), 'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(), 'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(), ]; diff --git a/apps/platform/app/Support/Filament/PanelThemeAsset.php b/apps/platform/app/Support/Filament/PanelThemeAsset.php index 0ecfa854..1e7f265f 100644 --- a/apps/platform/app/Support/Filament/PanelThemeAsset.php +++ b/apps/platform/app/Support/Filament/PanelThemeAsset.php @@ -6,19 +6,89 @@ class PanelThemeAsset { + /** + * @var array + */ + private static array $hotAssetReachability = []; + public static function resolve(string $entry): ?string { if (app()->runningUnitTests()) { return static::resolveFromManifest($entry); } - if (is_file(public_path('hot'))) { + if (static::shouldUseHotAsset($entry)) { return Vite::asset($entry); } return static::resolveFromManifest($entry); } + private static function shouldUseHotAsset(string $entry): bool + { + $hotFile = public_path('hot'); + + if (! is_file($hotFile)) { + return false; + } + + $hotUrl = trim((string) file_get_contents($hotFile)); + + if ($hotUrl === '') { + return false; + } + + $assetUrl = Vite::asset($entry); + + if ($assetUrl === '') { + return false; + } + + if (array_key_exists($assetUrl, static::$hotAssetReachability)) { + return static::$hotAssetReachability[$assetUrl]; + } + + $parts = parse_url($assetUrl); + + if (! is_array($parts)) { + return static::$hotAssetReachability[$assetUrl] = false; + } + + $host = $parts['host'] ?? null; + + if (! is_string($host) || $host === '') { + return static::$hotAssetReachability[$assetUrl] = false; + } + + $scheme = $parts['scheme'] ?? 'http'; + $port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80); + $transport = $scheme === 'https' ? 'ssl://' : ''; + $connection = @fsockopen($transport.$host, $port, $errorNumber, $errorMessage, 0.2); + + if (! is_resource($connection)) { + return static::$hotAssetReachability[$assetUrl] = false; + } + + $path = ($parts['path'] ?? '/').(isset($parts['query']) ? '?'.$parts['query'] : ''); + $hostHeader = isset($parts['port']) ? $host.':'.$port : $host; + + stream_set_timeout($connection, 0, 200000); + fwrite( + $connection, + "HEAD {$path} HTTP/1.1\r\nHost: {$hostHeader}\r\nConnection: close\r\n\r\n", + ); + + $statusLine = fgets($connection); + + fclose($connection); + + if (! is_string($statusLine)) { + return static::$hotAssetReachability[$assetUrl] = false; + } + + return static::$hotAssetReachability[$assetUrl] = preg_match('/^HTTP\/\d\.\d\s+[23]\d\d\b/', $statusLine) === 1; + } + private static function resolveFromManifest(string $entry): ?string { $manifest = public_path('build/manifest.json'); diff --git a/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php b/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php index 6eaa184a..9a86ed8f 100644 --- a/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php +++ b/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php @@ -202,7 +202,7 @@ public function auditTargetLink(AuditLog $record): ?array ->whereKey($resourceId) ->where('workspace_id', (int) $workspace->getKey()) ->exists() - ? ['label' => OperationRunLinks::openLabel(), 'url' => route('admin.operations.view', ['run' => $resourceId])] + ? ['label' => OperationRunLinks::openLabel(), 'url' => OperationRunLinks::tenantlessView($resourceId)] : null, 'baseline_profile' => $workspace instanceof Workspace && $this->workspaceCapabilityResolver->isMember($user, $workspace) diff --git a/apps/platform/app/Support/OperationRunLinks.php b/apps/platform/app/Support/OperationRunLinks.php index 6d16cf73..e4a9faa8 100644 --- a/apps/platform/app/Support/OperationRunLinks.php +++ b/apps/platform/app/Support/OperationRunLinks.php @@ -81,6 +81,7 @@ public static function index( ?string $activeTab = null, bool $allTenants = false, ?string $problemClass = null, + ?string $operationType = null, ): string { $parameters = $context?->toQuery() ?? []; @@ -106,6 +107,10 @@ public static function index( } } + if (is_string($operationType) && $operationType !== '') { + $parameters['tableFilters']['type']['value'] = $operationType; + } + return route('admin.operations.index', $parameters); } diff --git a/apps/platform/tests/Feature/078/RelatedLinksOnDetailTest.php b/apps/platform/tests/Feature/078/RelatedLinksOnDetailTest.php index f0d358ae..8c14bab2 100644 --- a/apps/platform/tests/Feature/078/RelatedLinksOnDetailTest.php +++ b/apps/platform/tests/Feature/078/RelatedLinksOnDetailTest.php @@ -7,6 +7,7 @@ use App\Models\OperationRun; use App\Models\RestoreRun; use App\Models\Tenant; +use App\Support\OperationRunLinks; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -63,7 +64,7 @@ public function test_shows_only_generic_links_for_tenantless_runs_on_canonical_d ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) ->assertOk() ->assertSee('Operations') - ->assertSee(route('admin.operations.index'), false) + ->assertSee(OperationRunLinks::index(), false) ->assertDontSee('View restore run'); } diff --git a/apps/platform/tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php b/apps/platform/tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php index 3bf10389..1c08fec4 100644 --- a/apps/platform/tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php +++ b/apps/platform/tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php @@ -75,6 +75,34 @@ public function test_trusts_notification_style_run_links_with_no_selected_tenant ->assertSee('Canonical workspace view'); } + public function test_uses_canonical_collection_link_for_default_back_and_show_all_fallbacks(): void + { + $runTenant = Tenant::factory()->create(); + [$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner'); + + $otherTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $runTenant->workspace_id, + ]); + + createUserWithTenant(tenant: $otherTenant, user: $user, role: 'owner'); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $runTenant->workspace_id, + 'tenant_id' => (int) $runTenant->getKey(), + 'type' => 'inventory_sync', + ]); + + Filament::setTenant($otherTenant, true); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $runTenant->workspace_id]) + ->get(OperationRunLinks::tenantlessView($run)) + ->assertOk() + ->assertSee('Back to Operations') + ->assertSee('Show all operations') + ->assertSee(OperationRunLinks::index(), false); + } + public function test_trusts_verification_surface_run_links_with_no_selected_tenant_context(): void { $tenant = Tenant::factory()->create(); diff --git a/apps/platform/tests/Feature/Filament/InventoryCoverageRunContinuityTest.php b/apps/platform/tests/Feature/Filament/InventoryCoverageRunContinuityTest.php index 02861282..84b8bbf2 100644 --- a/apps/platform/tests/Feature/Filament/InventoryCoverageRunContinuityTest.php +++ b/apps/platform/tests/Feature/Filament/InventoryCoverageRunContinuityTest.php @@ -4,9 +4,11 @@ use App\Filament\Pages\InventoryCoverage; use App\Filament\Resources\InventoryItemResource; +use App\Models\InventoryItem; use App\Models\OperationRun; use App\Models\Tenant; use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload; +use App\Support\OperationRunLinks; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -40,21 +42,14 @@ function seedCoverageBasisRun(Tenant $tenant): OperationRun $run = seedCoverageBasisRun($tenant); - $historyUrl = route('admin.operations.index', [ - 'tenant_id' => (int) $tenant->getKey(), - 'tableFilters' => [ - 'type' => [ - 'value' => 'inventory_sync', - ], - ], - ]); + $historyUrl = OperationRunLinks::index($tenant, operationType: 'inventory_sync'); $this->actingAs($user) ->get(InventoryCoverage::getUrl(tenant: $tenant)) ->assertOk() ->assertSee('Latest coverage-bearing sync completed') ->assertSee('Open basis run') - ->assertSee(route('admin.operations.view', ['run' => (int) $run->getKey()]), false) + ->assertSee(OperationRunLinks::view($run, $tenant), false) ->assertSee($historyUrl, false) ->assertSee('Review the cited inventory sync to inspect provider or permission issues in detail.'); }); @@ -78,6 +73,26 @@ function seedCoverageBasisRun(Tenant $tenant): OperationRun ->assertDontSee('Open basis run'); }); +it('shows the last inventory sync as a canonical admin operation detail link', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'type' => 'inventory_sync', + ]); + + $item = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'last_seen_operation_run_id' => (int) $run->getKey(), + ]); + + $this->actingAs($user) + ->get(InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant)) + ->assertOk() + ->assertSee('Last inventory sync') + ->assertSee(OperationRunLinks::view($run, $tenant), false); +}); + it('keeps the no-basis fallback explicit on the inventory items list', function (): void { $tenant = Tenant::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); diff --git a/apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php b/apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php index 683e2a2c..fc95c688 100644 --- a/apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php +++ b/apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php @@ -16,7 +16,7 @@ $this->actingAs($user); - OperationRun::factory()->create([ + $run = OperationRun::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'type' => 'provider.connection.check', @@ -32,6 +32,8 @@ ->assertSee('Open operation') ->assertSee(OperationRunLinks::openCollectionLabel()) ->assertSee(OperationRunLinks::collectionScopeDescription()) + ->assertSee(OperationRunLinks::index($tenant), false) + ->assertSee(OperationRunLinks::tenantlessView($run), false) ->assertSee('No action needed.') ->assertDontSee('No operations yet.'); }); diff --git a/apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php b/apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php new file mode 100644 index 00000000..c14d7c26 --- /dev/null +++ b/apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php @@ -0,0 +1,160 @@ + + */ +function operationRunLinkContractIncludePaths(): array +{ + $root = SourceFileScanner::projectRoot(); + + return [ + 'tenant_recent_operations_summary' => $root.'/app/Filament/Widgets/Tenant/RecentOperationsSummary.php', + 'inventory_coverage' => $root.'/app/Filament/Pages/InventoryCoverage.php', + 'inventory_item_resource' => $root.'/app/Filament/Resources/InventoryItemResource.php', + 'review_pack_resource' => $root.'/app/Filament/Resources/ReviewPackResource.php', + 'tenantless_operation_run_viewer' => $root.'/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php', + 'related_navigation_resolver' => $root.'/app/Support/Navigation/RelatedNavigationResolver.php', + 'system_directory_tenant' => $root.'/app/Filament/System/Pages/Directory/ViewTenant.php', + 'system_directory_workspace' => $root.'/app/Filament/System/Pages/Directory/ViewWorkspace.php', + 'system_ops_runs' => $root.'/app/Filament/System/Pages/Ops/Runs.php', + 'system_ops_view_run' => $root.'/app/Filament/System/Pages/Ops/ViewRun.php', + 'admin_panel_provider' => $root.'/app/Providers/Filament/AdminPanelProvider.php', + 'tenant_panel_provider' => $root.'/app/Providers/Filament/TenantPanelProvider.php', + 'ensure_filament_tenant_selected' => $root.'/app/Support/Middleware/EnsureFilamentTenantSelected.php', + 'clear_tenant_context_controller' => $root.'/app/Http/Controllers/ClearTenantContextController.php', + 'operation_run_url_delegate' => $root.'/app/Support/OpsUx/OperationRunUrl.php', + ]; +} + +/** + * @return array + */ +function operationRunLinkContractAllowlist(): array +{ + $paths = operationRunLinkContractIncludePaths(); + + return [ + $paths['admin_panel_provider'] => 'Admin panel navigation is bootstrapping infrastructure and intentionally links to the canonical collection route before request-scoped navigation context exists.', + $paths['tenant_panel_provider'] => 'Tenant panel navigation is bootstrapping infrastructure and intentionally links to the canonical collection route before tenant-specific helper context is owned by the source surface.', + $paths['ensure_filament_tenant_selected'] => 'Tenant-selection middleware owns redirect/navigation fallback infrastructure and must not fabricate source-surface navigation context.', + $paths['clear_tenant_context_controller'] => 'Clear-tenant redirects preserve an explicit redirect contract and cannot depend on UI helper context.', + ]; +} + +/** + * @param array $paths + * @param array $allowlist + * @return list + */ +function operationRunLinkContractViolations(array $paths, array $allowlist = []): array +{ + $patterns = [ + [ + 'pattern' => '/route\(\s*[\'"]admin\.operations\.index[\'"]/', + 'expectedHelper' => 'OperationRunLinks::index(...)', + 'reason' => 'Raw admin operations collection route assembly bypasses the canonical admin link helper.', + ], + [ + 'pattern' => '/route\(\s*[\'"]admin\.operations\.view[\'"]/', + 'expectedHelper' => 'OperationRunLinks::view(...) or OperationRunLinks::tenantlessView(...)', + 'reason' => 'Raw admin operation detail route assembly bypasses the canonical admin link helper.', + ], + [ + 'pattern' => '/[\'"]\/system\/ops\/runs(?:\/[^\'"]*)?[\'"]/', + 'expectedHelper' => 'SystemOperationRunLinks::index() or SystemOperationRunLinks::view(...)', + 'reason' => 'Direct system operations path assembly bypasses the canonical system link helper.', + ], + [ + 'pattern' => '/\b(?:Runs|ViewRun)::getUrl\(/', + 'expectedHelper' => 'SystemOperationRunLinks::index() or SystemOperationRunLinks::view(...)', + 'reason' => 'Direct system operations page URL generation belongs behind the canonical system link helper.', + ], + ]; + + $violations = []; + + foreach ($paths as $path) { + if (array_key_exists($path, $allowlist)) { + continue; + } + + $source = SourceFileScanner::read($path); + $lines = preg_split('/\R/', $source) ?: []; + + foreach ($lines as $index => $line) { + foreach ($patterns as $pattern) { + if (preg_match($pattern['pattern'], $line) !== 1) { + continue; + } + + $violations[] = [ + 'file' => SourceFileScanner::relativePath($path), + 'line' => $index + 1, + 'snippet' => SourceFileScanner::snippet($source, $index + 1), + 'expectedHelper' => $pattern['expectedHelper'], + 'reason' => $pattern['reason'], + ]; + } + } + } + + return $violations; +} + +it('keeps covered operation run link producers on canonical helper families', function (): void { + $paths = operationRunLinkContractIncludePaths(); + $allowlist = operationRunLinkContractAllowlist(); + + $violations = operationRunLinkContractViolations($paths, $allowlist); + + expect($violations)->toBeEmpty(); +})->group('surface-guard'); + +it('keeps the operation run link exception boundary explicit and infrastructure-owned', function (): void { + $allowlist = operationRunLinkContractAllowlist(); + + expect(array_keys($allowlist))->toHaveCount(4); + + foreach ($allowlist as $reason) { + expect($reason) + ->not->toBe('') + ->not->toContain('convenience'); + } + + foreach (array_keys($allowlist) as $path) { + expect(SourceFileScanner::read($path))->toContain("route('admin.operations.index')"); + } +})->group('surface-guard'); + +it('reports actionable file and snippet output for a representative raw bypass', function (): void { + $probePath = storage_path('framework/testing/OperationRunLinkContractProbe.php'); + + if (! is_dir(dirname($probePath))) { + mkdir(dirname($probePath), 0777, true); + } + + file_put_contents($probePath, <<<'PHP' + 123]); +PHP); + + try { + $violations = operationRunLinkContractViolations([ + 'probe' => $probePath, + ]); + } finally { + @unlink($probePath); + } + + expect($violations)->toHaveCount(1) + ->and($violations[0]['file'])->toContain('OperationRunLinkContractProbe.php') + ->and($violations[0]['line'])->toBe(3) + ->and($violations[0]['snippet'])->toContain("route('admin.operations.view'") + ->and($violations[0]['expectedHelper'])->toContain('OperationRunLinks::view') + ->and($violations[0]['reason'])->toContain('bypasses the canonical admin link helper'); +})->group('surface-guard'); diff --git a/apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php b/apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php index 7620b366..48a0ec19 100644 --- a/apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php +++ b/apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php @@ -5,6 +5,8 @@ use App\Models\OperationRun; use App\Models\Tenant; use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationRunUrl; +use App\Support\System\SystemOperationRunLinks; use Illuminate\Support\Facades\File; it('routes all OperationRun view links through OperationRunLinks', function (): void { @@ -82,3 +84,38 @@ 'problemClass' => OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP, ])); })->group('ops-ux'); + +it('preserves helper-owned operation type filters on canonical operations collection links', function (): void { + $tenant = Tenant::factory()->create(); + + expect(OperationRunLinks::index($tenant, operationType: 'inventory_sync')) + ->toBe(route('admin.operations.index', [ + 'tenant_id' => (int) $tenant->getKey(), + 'tableFilters' => [ + 'type' => [ + 'value' => 'inventory_sync', + ], + ], + ])); +})->group('ops-ux'); + +it('keeps the thin operation URL delegate on the canonical admin helpers', function (): void { + $tenant = Tenant::factory()->create(); + $run = OperationRun::factory()->for($tenant)->create(); + + expect(OperationRunUrl::view($run, $tenant)) + ->toBe(OperationRunLinks::view($run, $tenant)) + ->and(OperationRunUrl::index($tenant)) + ->toBe(OperationRunLinks::index($tenant)); +})->group('ops-ux'); + +it('resolves system operation links through the canonical system helper family', function (): void { + $run = OperationRun::factory()->create(); + + expect(SystemOperationRunLinks::index()) + ->toBe(\App\Filament\System\Pages\Ops\Runs::getUrl(panel: 'system')) + ->and(SystemOperationRunLinks::view($run)) + ->toBe(\App\Filament\System\Pages\Ops\ViewRun::getUrl(['run' => (int) $run->getKey()], panel: 'system')) + ->and(SystemOperationRunLinks::view((int) $run->getKey())) + ->toBe(\App\Filament\System\Pages\Ops\ViewRun::getUrl(['run' => (int) $run->getKey()], panel: 'system')); +})->group('ops-ux'); diff --git a/apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php b/apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php index acdb2c0c..4082c874 100644 --- a/apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php +++ b/apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php @@ -15,6 +15,7 @@ use App\Services\ReviewPackService; use App\Support\Auth\UiTooltips; use App\Support\Evidence\EvidenceSnapshotStatus; +use App\Support\OperationRunLinks; use App\Support\ReviewPackStatus; use Filament\Actions\ActionGroup; use Filament\Facades\Filament; @@ -320,12 +321,16 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot $tenant = Tenant::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $snapshot = seedReviewPackEvidence($tenant); + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'type' => 'tenant.review_pack.generate', + ]); $pack = ReviewPack::factory()->ready()->create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'initiated_by_user_id' => (int) $user->getKey(), 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'operation_run_id' => (int) $run->getKey(), 'summary' => [ 'finding_count' => 5, 'report_count' => 2, @@ -352,6 +357,7 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot ->assertDontSee('Artifact truth') ->assertSee('Publishable') ->assertSee('#'.$snapshot->getKey()) + ->assertSee(OperationRunLinks::view($run, $tenant), false) ->assertSee('resolved'); }); diff --git a/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php b/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php index 8a9447e9..232fc0fc 100644 --- a/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php +++ b/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php @@ -2,9 +2,11 @@ declare(strict_types=1); +use App\Models\OperationRun; use App\Models\PlatformUser; use App\Models\User; use App\Support\Auth\PlatformCapabilities; +use App\Support\System\SystemOperationRunLinks; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -35,6 +37,46 @@ '/system/ops/runs', ]); +it('returns 404 when a tenant session accesses a system operation detail route', function () { + $user = User::factory()->create(); + $run = OperationRun::factory()->create(); + + $this->actingAs($user) + ->get(SystemOperationRunLinks::view($run)) + ->assertNotFound(); +}); + +it('returns 403 when a platform user lacks operations capability on system operation detail', function () { + $platformUser = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + ], + 'is_active' => true, + ]); + + $run = OperationRun::factory()->create(); + + $this->actingAs($platformUser, 'platform') + ->get(SystemOperationRunLinks::view($run)) + ->assertForbidden(); +}); + +it('returns 200 on system operation detail when a platform user has operations capability', function () { + $platformUser = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPERATIONS_VIEW, + ], + 'is_active' => true, + ]); + + $run = OperationRun::factory()->create(); + + $this->actingAs($platformUser, 'platform') + ->get(SystemOperationRunLinks::view($run)) + ->assertSuccessful(); +}); + it('returns 200 when a platform user has the required capability', function () { $platformUser = PlatformUser::factory()->create([ 'capabilities' => [ diff --git a/apps/platform/tests/Unit/Filament/PanelThemeAssetTest.php b/apps/platform/tests/Unit/Filament/PanelThemeAssetTest.php index 65084420..5543864c 100644 --- a/apps/platform/tests/Unit/Filament/PanelThemeAssetTest.php +++ b/apps/platform/tests/Unit/Filament/PanelThemeAssetTest.php @@ -5,11 +5,13 @@ beforeEach(function (): void { $this->originalPublicPath = public_path(); + $this->originalEnvironment = app()->environment(); $this->temporaryPublicPath = null; }); afterEach(function (): void { app()->usePublicPath($this->originalPublicPath); + app()->instance('env', $this->originalEnvironment); if (is_string($this->temporaryPublicPath) && File::isDirectory($this->temporaryPublicPath)) { File::deleteDirectory($this->temporaryPublicPath); @@ -69,6 +71,27 @@ function useTemporaryPublicPath(): string ->not->toContain(':5173'); }); +it('falls back to the built manifest asset when the Vite hot server is unreachable', function (): void { + $publicPath = useTemporaryPublicPath(); + + app()->instance('env', 'local'); + + File::ensureDirectoryExists($publicPath.'/build'); + File::put($publicPath.'/hot', 'http://127.0.0.1:1'); + File::put( + $publicPath.'/build/manifest.json', + json_encode([ + 'resources/css/filament/admin/theme.css' => [ + 'file' => 'assets/theme-test.css', + ], + ], JSON_THROW_ON_ERROR), + ); + + expect(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css')) + ->toEndWith('/build/assets/theme-test.css') + ->not->toContain(':1'); +}); + it('returns null when the build manifest contains invalid json', function (): void { $publicPath = useTemporaryPublicPath(); diff --git a/specs/232-operation-run-link-contract/checklists/requirements.md b/specs/232-operation-run-link-contract/checklists/requirements.md new file mode 100644 index 00000000..77f787d7 --- /dev/null +++ b/specs/232-operation-run-link-contract/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Operation Run Link Contract Enforcement + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-23 +**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/232-operation-run-link-contract/spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation pass 1 completed on 2026-04-23. +- The spec stays intentionally narrow: existing helper families remain the contract, and the feature only standardizes adoption plus a bounded allowlist guard. +- A few requirement lines necessarily name existing shared contract classes and canonical routes because the subject of the spec is contract enforcement on those existing platform surfaces. The spec avoids prescribing implementation structure beyond reuse of the already-shipped canonical paths. diff --git a/specs/232-operation-run-link-contract/contracts/operation-run-link-contract.logical.openapi.yaml b/specs/232-operation-run-link-contract/contracts/operation-run-link-contract.logical.openapi.yaml new file mode 100644 index 00000000..feefc14d --- /dev/null +++ b/specs/232-operation-run-link-contract/contracts/operation-run-link-contract.logical.openapi.yaml @@ -0,0 +1,380 @@ +openapi: 3.1.0 +info: + title: Operation Run Link Contract Enforcement + version: 1.0.0 + summary: Logical internal contract for Spec 232 canonical admin and system operation-run links plus bounded guard enforcement. + description: | + This contract documents the internal helper-owned URL semantics that Spec 232 enforces. + It is intentionally logical rather than a public HTTP API because the feature reuses + existing Filament pages, helper families, and route ownership instead of introducing + a new controller namespace. +servers: + - url: https://logical.internal + description: Non-routable placeholder used to describe internal repository contracts. +paths: + /internal/operation-run-links/admin/collection: + post: + summary: Build the canonical admin operations collection URL for a covered source surface. + operationId: buildAdminOperationCollectionLink + x-not-public-http: true + requestBody: + required: true + content: + application/vnd.tenantpilot.admin-operation-collection-input+json: + schema: + $ref: '#/components/schemas/AdminOperationCollectionLinkInput' + responses: + '200': + description: Canonical admin collection link emitted through `OperationRunLinks::index(...)`. + content: + application/vnd.tenantpilot.operation-link+json: + schema: + $ref: '#/components/schemas/CanonicalOperationLink' + /internal/operation-run-links/admin/detail: + post: + summary: Build the canonical admin operation detail URL for a covered source surface. + operationId: buildAdminOperationDetailLink + x-not-public-http: true + requestBody: + required: true + content: + application/vnd.tenantpilot.admin-operation-detail-input+json: + schema: + $ref: '#/components/schemas/AdminOperationDetailLinkInput' + responses: + '200': + description: Canonical admin detail link emitted through `OperationRunLinks::view(...)` or `tenantlessView(...)`. + content: + application/vnd.tenantpilot.operation-link+json: + schema: + $ref: '#/components/schemas/CanonicalOperationLink' + /internal/operation-run-links/system/collection: + post: + summary: Build the canonical system operations collection URL for a covered system source surface. + operationId: buildSystemOperationCollectionLink + x-not-public-http: true + requestBody: + required: true + content: + application/vnd.tenantpilot.system-operation-collection-input+json: + schema: + $ref: '#/components/schemas/SystemOperationCollectionLinkInput' + responses: + '200': + description: Canonical system collection link emitted through `SystemOperationRunLinks::index()`. + content: + application/vnd.tenantpilot.operation-link+json: + schema: + $ref: '#/components/schemas/CanonicalOperationLink' + /internal/operation-run-links/system/detail: + post: + summary: Build the canonical system operation detail URL for a covered system source surface. + operationId: buildSystemOperationDetailLink + x-not-public-http: true + requestBody: + required: true + content: + application/vnd.tenantpilot.system-operation-detail-input+json: + schema: + $ref: '#/components/schemas/SystemOperationDetailLinkInput' + responses: + '200': + description: Canonical system detail link emitted through `SystemOperationRunLinks::view(...)`. + content: + application/vnd.tenantpilot.operation-link+json: + schema: + $ref: '#/components/schemas/CanonicalOperationLink' + /internal/guards/operation-run-link-contract/check: + post: + summary: Scan the bounded app-side source surface for raw operation-run link bypasses. + operationId: checkOperationRunLinkContract + x-not-public-http: true + requestBody: + required: true + content: + application/vnd.tenantpilot.operation-run-link-guard-input+json: + schema: + $ref: '#/components/schemas/OperationRunLinkGuardCheck' + responses: + '200': + description: Guard completed and found no violations inside the declared boundary. + content: + application/vnd.tenantpilot.operation-run-link-guard-report+json: + schema: + $ref: '#/components/schemas/OperationRunLinkGuardReport' + '422': + description: Guard found one or more raw bypasses outside the allowlist. + content: + application/vnd.tenantpilot.operation-run-link-guard-report+json: + schema: + $ref: '#/components/schemas/OperationRunLinkGuardReport' + /admin/operations: + get: + summary: Existing canonical admin operations collection route. + operationId: openAdminOperationsCollection + responses: + '200': + description: Admin monitoring collection renders for an entitled workspace operator. + content: + text/html: + schema: + type: string + '403': + description: Actor is in scope but lacks the required capability. + '404': + description: Actor is not entitled to the workspace or tenant-bound records referenced by the current context. + /admin/operations/{run}: + get: + summary: Existing canonical admin operation detail route. + operationId: openAdminOperationDetail + parameters: + - name: run + in: path + required: true + schema: + type: integer + responses: + '200': + description: Canonical admin run detail renders for an entitled operator. + content: + text/html: + schema: + type: string + '403': + description: Actor is a member in scope but lacks current capability. + '404': + description: Actor cannot access the run because of workspace, tenant, or plane isolation. + /system/ops/runs: + get: + summary: Existing canonical system operations collection route. + operationId: openSystemOperationsCollection + responses: + '200': + description: System monitoring collection renders for an entitled platform user. + content: + text/html: + schema: + type: string + '403': + description: Platform user lacks the required system operations capability. + '404': + description: Actor is not entitled to the system plane. + /system/ops/runs/{run}: + get: + summary: Existing canonical system operation detail route. + operationId: openSystemOperationDetail + parameters: + - name: run + in: path + required: true + schema: + type: integer + responses: + '200': + description: Canonical system run detail renders for an entitled platform user. + content: + text/html: + schema: + type: string + '403': + description: Platform user lacks the required system operations capability. + '404': + description: Actor is not entitled to the system plane or the run is not visible there. +components: + schemas: + OperationPlane: + type: string + enum: + - admin + - system + OperationLinkKind: + type: string + enum: + - collection + - detail + CoveredSurfaceKey: + type: string + description: Stable identifier for the source surface that emits the canonical link. + CanonicalNavigationContextInput: + type: object + description: | + Opaque helper-owned navigation context payload passed through the admin helper family + when a source surface needs canonical back-link or query continuity. + additionalProperties: true + AdminOperationCollectionLinkInput: + type: object + required: + - surfaceKey + properties: + surfaceKey: + $ref: '#/components/schemas/CoveredSurfaceKey' + tenantId: + type: integer + tenantExternalId: + type: string + navigationContext: + $ref: '#/components/schemas/CanonicalNavigationContextInput' + activeTab: + type: string + problemClass: + type: string + operationType: + type: string + description: Optional helper-owned operations table type filter, used by inventory coverage history links. + allTenants: + type: boolean + default: false + description: Canonical input for `OperationRunLinks::index(...)`. + AdminOperationDetailLinkInput: + type: object + required: + - surfaceKey + - runId + properties: + surfaceKey: + $ref: '#/components/schemas/CoveredSurfaceKey' + runId: + type: integer + navigationContext: + $ref: '#/components/schemas/CanonicalNavigationContextInput' + description: Canonical input for `OperationRunLinks::view(...)` or `tenantlessView(...)`. + SystemOperationCollectionLinkInput: + type: object + required: + - surfaceKey + properties: + surfaceKey: + $ref: '#/components/schemas/CoveredSurfaceKey' + description: Canonical input for `SystemOperationRunLinks::index()`. + SystemOperationDetailLinkInput: + type: object + required: + - surfaceKey + - runId + properties: + surfaceKey: + $ref: '#/components/schemas/CoveredSurfaceKey' + runId: + type: integer + description: Canonical input for `SystemOperationRunLinks::view(...)`. + CanonicalOperationLink: + type: object + required: + - label + - url + - plane + - kind + - canonicalNoun + properties: + label: + type: string + url: + type: string + plane: + $ref: '#/components/schemas/OperationPlane' + kind: + $ref: '#/components/schemas/OperationLinkKind' + canonicalNoun: + type: string + example: Operation + preservedQueryKeys: + type: array + items: + type: string + description: Helper-owned operator-facing link output for canonical operations destinations. + OperationRunLinkGuardCheck: + type: object + required: + - includePaths + - allowlistedPaths + - forbiddenPatterns + properties: + includePaths: + type: array + items: + type: string + examples: + - - app/Filament/Widgets/Tenant/RecentOperationsSummary.php + - app/Filament/Pages/InventoryCoverage.php + - app/Filament/Resources/InventoryItemResource.php + - app/Filament/Resources/ReviewPackResource.php + - app/Filament/Pages/Operations/TenantlessOperationRunViewer.php + - app/Support/Navigation/RelatedNavigationResolver.php + - app/Filament/System/Pages/Directory/ViewTenant.php + - app/Filament/System/Pages/Directory/ViewWorkspace.php + - app/Filament/System/Pages/Ops/Runs.php + - app/Filament/System/Pages/Ops/ViewRun.php + - app/Providers/Filament/AdminPanelProvider.php + - app/Providers/Filament/TenantPanelProvider.php + - app/Support/Middleware/EnsureFilamentTenantSelected.php + - app/Http/Controllers/ClearTenantContextController.php + - app/Support/OpsUx/OperationRunUrl.php + allowlistedPaths: + type: array + items: + type: string + examples: + - - app/Providers/Filament/AdminPanelProvider.php + - app/Providers/Filament/TenantPanelProvider.php + - app/Support/Middleware/EnsureFilamentTenantSelected.php + - app/Http/Controllers/ClearTenantContextController.php + forbiddenPatterns: + type: array + items: + type: string + examples: + - - "route('admin.operations.index'" + - "route('admin.operations.view'" + - "/system/ops/runs" + - "Runs::getUrl(" + - "ViewRun::getUrl(" + acceptedDelegates: + type: array + items: + type: string + examples: + - - app/Support/OpsUx/OperationRunUrl.php + description: Declares the bounded source surface and explicit exceptions for the guard. + OperationRunLinkGuardViolation: + type: object + required: + - filePath + - line + - snippet + - reason + properties: + filePath: + type: string + line: + type: integer + snippet: + type: string + expectedHelper: + type: string + reason: + type: string + description: Actionable failure output for one raw bypass. + OperationRunLinkGuardReport: + type: object + required: + - scannedPaths + - allowlistedPaths + - violations + properties: + scannedPaths: + type: array + items: + type: string + allowlistedPaths: + type: array + items: + type: string + acceptedDelegates: + type: array + items: + type: string + violations: + type: array + items: + $ref: '#/components/schemas/OperationRunLinkGuardViolation' + description: Report shape returned by the bounded guard check. diff --git a/specs/232-operation-run-link-contract/data-model.md b/specs/232-operation-run-link-contract/data-model.md new file mode 100644 index 00000000..b35e891f --- /dev/null +++ b/specs/232-operation-run-link-contract/data-model.md @@ -0,0 +1,199 @@ +# Data Model: Operation Run Link Contract Enforcement + +## Overview + +This feature introduces no new persisted business entity. Existing `OperationRun` records, workspace and tenant authorization truth, and canonical operations destination pages remain authoritative. The new work is a derived link-generation and guard contract over those existing records and helper families. + +## Existing Persistent Entities + +### OperationRun + +**Purpose**: Canonical runtime and monitoring truth for operation collection and detail destinations. + +**Key fields used by this feature**: + +- `id` +- `workspace_id` +- `tenant_id` +- `type` +- `status` +- `outcome` +- `context` + +**Rules relevant to this feature**: + +- Admin-plane and system-plane detail links resolve to existing canonical monitoring surfaces; the feature does not add a new route family. +- Tenant-bound runs remain subject to destination-side entitlement checks even when the source link carries canonical tenant continuity. +- The feature changes how source surfaces build URLs, not how `OperationRun` lifecycle truth is persisted. + +### Tenant + +**Purpose**: Existing tenant scope and entitlement anchor for admin-plane collection continuity and tenant-bound run inspection. + +**Key fields used by this feature**: + +- `id` +- `external_id` +- `workspace_id` +- `name` + +**Rules relevant to this feature**: + +- Admin-plane collection links may preserve entitled tenant context only through helper-supported parameters. +- Detail links never create a tenant-prefixed duplicate route; tenant relevance is enforced at the destination against the run itself. + +### Workspace + +**Purpose**: Existing workspace isolation boundary for canonical admin monitoring routes. + +**Key fields used by this feature**: + +- `id` +- membership and capability truth via existing authorization helpers + +**Rules relevant to this feature**: + +- Non-members remain `404` on canonical admin monitoring routes. +- The feature does not add any new workspace-scoped persistence or copied navigation records. + +## Derived Models + +### AdminOperationCollectionLinkInput + +**Purpose**: Canonical input model for helper-owned admin collection links. + +**Fields**: + +- `surfaceKey` +- `tenantId` or `tenantExternalId` when the source surface owns entitled tenant continuity +- `navigationContext` +- `activeTab` +- `problemClass` +- `allTenants` + +**Validation rules**: + +- Tenant context is included only when the source surface already owns an entitled tenant. +- `activeTab`, `problemClass`, and `allTenants` remain limited to current helper-supported semantics. +- Collection URLs are always emitted by `OperationRunLinks::index(...)`. + +### AdminOperationDetailLinkInput + +**Purpose**: Canonical input model for helper-owned admin detail links. + +**Fields**: + +- `surfaceKey` +- `runId` +- `navigationContext` + +**Validation rules**: + +- Detail links are emitted only through `OperationRunLinks::view(...)` or `OperationRunLinks::tenantlessView(...)`. +- No source surface may mint a tenant-prefixed or surface-local duplicate detail route. + +### SystemOperationCollectionLinkInput + +**Purpose**: Canonical input model for helper-owned system collection links. + +**Fields**: + +- `surfaceKey` + +**Validation rules**: + +- Collection links are emitted only through `SystemOperationRunLinks::index()`. +- System-plane collection links never fall back to admin-plane monitoring. + +### SystemOperationDetailLinkInput + +**Purpose**: Canonical input model for helper-owned system detail links. + +**Fields**: + +- `surfaceKey` +- `runId` + +**Validation rules**: + +- Detail links are emitted only through `SystemOperationRunLinks::view(...)`. +- System-plane detail links never fall back to admin-plane monitoring. + +### CoveredLinkProducer + +**Purpose**: Planning and guard model for every app-side source that emits an `OperationRun` collection or detail link. + +**Fields**: + +- `surfaceKey` +- `filePath` +- `plane` (`admin`, `system`) +- `linkKind` (`collection`, `detail`, `both`) +- `contractState` (`migrated`, `verified_helper_backed`, `allowlisted_exception`) +- `justification` + +**State transitions**: + +- `raw_bypass` -> `migrated` +- `raw_bypass` -> `allowlisted_exception` +- `existing_helper_path` -> `verified_helper_backed` +- `thin_delegate` -> `verified_helper_backed` + +**Rules**: + +- Every first-slice producer must end in either `migrated`, `verified_helper_backed`, or `allowlisted_exception`. +- `allowlisted_exception` is valid only for infrastructure or redirect code that should not absorb UI-context dependencies. +- `verified_helper_backed` is valid for already-converged system producers and thin delegates that forward directly to the canonical helper family. + +### OperationRunLinkGuardReport + +**Purpose**: Derived failure output for the bounded regression guard. + +**Fields**: + +- `scannedPaths[]` +- `allowlistedPaths[]` +- `acceptedDelegates[]` +- `violations[]` + +### GuardViolation + +**Purpose**: Actionable output for a newly detected raw bypass. + +**Fields**: + +- `filePath` +- `line` +- `snippet` +- `expectedHelper` (optional) +- `reason` + +**Rules**: + +- When present, `expectedHelper` points to a concrete replacement path such as `OperationRunLinks::index(...)`, `OperationRunLinks::view(...)`, or `SystemOperationRunLinks::view(...)`. +- Violations are limited to the declared guard boundary and must not report tests or helper implementations themselves. + +## Consumer Matrix + +| Producer | Plane | Link kinds | Target state | +|----------|-------|------------|--------------| +| `RecentOperationsSummary` | admin | collection | migrated | +| `InventoryCoverage` | admin | collection + detail | migrated | +| `InventoryItemResource` | admin | detail | migrated | +| `ReviewPackResource` | admin | detail | migrated | +| `TenantlessOperationRunViewer` | admin | collection fallbacks | migrated | +| `RelatedNavigationResolver` | admin | detail | migrated | +| `AdminPanelProvider` | admin | collection nav shortcut | allowlisted exception | +| `TenantPanelProvider` | admin | collection nav shortcut | allowlisted exception | +| `EnsureFilamentTenantSelected` | admin | collection redirect shortcut | allowlisted exception | +| `ClearTenantContextController` | admin | collection redirect fallback | allowlisted exception | +| `ViewTenant` | system | collection + detail | verified helper-backed | +| `ViewWorkspace` | system | collection + detail | verified helper-backed | +| `Runs` | system | collection + detail | verified helper-backed | +| `ViewRun` | system | collection + detail | verified helper-backed | + +## Persistence Boundaries + +- No new table, enum-backed state, cache record, or presentation-only persistence is introduced. +- The producer inventory and allowlist are repository-level planning and guard artifacts, not product-domain records. +- Canonical navigation context remains derived request state owned by existing helper and navigation abstractions. \ No newline at end of file diff --git a/specs/232-operation-run-link-contract/plan.md b/specs/232-operation-run-link-contract/plan.md new file mode 100644 index 00000000..9e9c510e --- /dev/null +++ b/specs/232-operation-run-link-contract/plan.md @@ -0,0 +1,230 @@ +# Implementation Plan: Operation Run Link Contract Enforcement + +**Branch**: `232-operation-run-link-contract` | **Date**: 2026-04-23 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/232-operation-run-link-contract/spec.md` + +## Summary + +Enforce one canonical contract for platform-owned `OperationRun` collection and detail links by migrating confirmed raw admin-plane producers to `OperationRunLinks`, preserving and regression-protecting the existing system-plane helper path through `SystemOperationRunLinks`, recording only the narrow infrastructure exceptions that cannot safely depend on the helper family, and adding one bounded regression guard that blocks new raw bypasses inside the declared UI and shared-navigation boundary. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 +**Primary Dependencies**: Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers +**Storage**: PostgreSQL-backed existing `operation_runs`, `tenants`, and `workspaces` records plus current session-backed canonical navigation state; no new persistence +**Testing**: Focused Pest feature tests for canonical link behavior, representative admin drill-throughs, admin and system authorization semantics, and one bounded guard test +**Validation Lanes**: `fast-feedback`, `confidence` +**Target Platform**: Laravel admin web application running in Sail Linux containers, with admin plane at `/admin` and platform plane at `/system` +**Project Type**: Monorepo with one Laravel runtime in `apps/platform` and spec artifacts at repository root +**Performance Goals**: Link generation remains helper-owned string construction with no new remote work, no new persisted navigation state, and no broadened destination queries beyond existing canonical helper semantics +**Constraints**: No new operations route family, no second link presenter stack, no compatibility shim layer, no weakening of `404` versus `403` semantics, and no repo-wide guard broadening beyond platform-owned UI and shared navigation code in this slice +**Scale/Scope**: Migrate confirmed raw admin-plane producers in widgets, pages, resources, and shared navigation; validate already-helper-backed system-plane producers; keep bootstrapping and redirect exceptions explicit and narrow + +## Filament v5 Implementation Contract + +- **Livewire v4.0+ compliance**: Preserved. The feature stays inside existing Filament v5 and Livewire v4 primitives and does not introduce legacy Livewire v3 patterns. +- **Provider registration location**: Unchanged. Panel providers remain registered in `bootstrap/providers.php`, not `bootstrap/app.php`. +- **Global search coverage**: + - `InventoryItemResource` remains compatible with global search expectations because it already exposes a `view` page. + - `ReviewPackResource` keeps global search disabled via `$isGloballySearchable = false` while still exposing a `view` page for direct navigation. + - The affected pages, widgets, and shared navigation helpers do not introduce or change any additional global-search surface. +- **Destructive actions**: No new destructive actions are introduced. Existing destructive or destructive-like actions on operations surfaces remain governed by their current `Action::make(...)->action(...)` definitions, and existing system run-detail actions already retain `->requiresConfirmation()`. +- **Asset strategy**: No new panel-only or shared assets are planned. Deployment expectations remain unchanged, including `cd apps/platform && php artisan filament:assets` only when registered Filament assets change. +- **Testing plan**: Prove the feature with focused feature coverage on canonical admin links, representative dashboard and shared-resolver drill-throughs, explicit admin `404`/`403` authorization preservation, the new bounded guard, and system-plane continuity plus authorization semantics. No browser or heavy-governance family is needed for this plan. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: Changed admin monitoring collection/detail link producers, shared related-navigation builders, helper-owned URL-query continuity, and system-plane regression protection for canonical operations links +- **Native vs custom classification summary**: Mixed shared-family change using native Filament resources/pages/widgets plus existing shared link helpers +- **Shared-family relevance**: Navigation, action links, related links, deep links, and canonical monitoring entry points +- **State layers in scope**: `page`, `detail`, and helper-owned `URL-query` continuity +- **Handling modes by drift class or surface**: Hard-stop for new raw bypasses inside the declared guard boundary; review-mandatory for explicit infrastructure exceptions +- **Repository-signal treatment**: Review-mandatory because the feature adds a bounded repo guard and a named exception list +- **Special surface test profiles**: `standard-native-filament`, `monitoring-state-page` +- **Required tests or manual smoke**: `functional-core`, `state-contract`, `manual-smoke` +- **Exception path and spread control**: One named exception boundary for bootstrapping, middleware, and redirect surfaces that cannot safely depend on runtime navigation context; each retained path must be file-specific and justified +- **Active feature PR close-out entry**: `Guardrail` + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `OperationRunLinks`, `SystemOperationRunLinks`, `CanonicalNavigationContext`, `RecentOperationsSummary`, `InventoryCoverage`, `InventoryItemResource`, `ReviewPackResource`, `TenantlessOperationRunViewer`, `RelatedNavigationResolver`, panel navigation providers, tenant-selection middleware, and clear-tenant-context redirect behavior +- **Shared abstractions reused**: `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`; the existing `App\Support\OpsUx\OperationRunUrl` wrapper remains acceptable where it simply delegates to `OperationRunLinks` +- **New abstraction introduced? why?**: none; the only new structure is a test-local allowlist boundary for legitimate raw producers +- **Why the existing abstraction was sufficient or insufficient**: The helper families already encode canonical labels, admin/system plane selection, entitled tenant continuity, and canonical query semantics. The only current-release gap is adoption and enforcement on specific producers that still assemble routes locally. +- **Bounded deviation / spread control**: Infrastructure-only exceptions are explicit, file-scoped, and non-precedential. UI surfaces, shared navigation, and related-link builders stay on the helper path. + +## Constitution Check + +*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design: still passed with one bounded guard and no new persisted truth.* + +| Gate | Status | Plan Notes | +|------|--------|------------| +| Inventory-first / read-write separation | PASS | The feature changes link generation only. It introduces no new writes, no new restore or remediation flow, and no new persisted artifact. | +| RBAC, workspace isolation, tenant isolation | PASS | Admin-plane destinations keep workspace and tenant entitlement checks; system-plane destinations remain platform-only; cross-plane access stays `404`. | +| Run observability / Ops-UX | PASS | Existing operations pages remain the canonical monitoring destination, but the feature does not start, mutate, or reclassify `OperationRun` lifecycle behavior. | +| Shared pattern first | PASS | The plan converges on `OperationRunLinks` and `SystemOperationRunLinks` instead of introducing a second link presenter or navigation framework. | +| Proportionality / no premature abstraction | PASS | The change is confined to migrating confirmed producers plus one bounded guard allowlist. No new registry, resolver family, or persisted truth is introduced. | +| UI semantics / Filament-native discipline | PASS | Existing Filament pages, resources, widgets, and navigation items remain intact; only helper-owned URLs change. No ad-hoc UI replacement or new action chrome is introduced. | +| Test governance | PASS | Proof stays in focused feature lanes with a narrow guard boundary. No browser lane or heavy-governance promotion is required for this slice. | + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Feature` for link continuity, representative admin drill-throughs, authorization-path preservation, and the bounded guard +- **Affected validation lanes**: `fast-feedback`, `confidence` +- **Why this lane mix is the narrowest sufficient proof**: The business truth is helper-owned URL generation, correct plane and scope continuity, and bounded prevention of new raw bypasses. Those behaviors are fully provable with targeted feature tests and one repo-guard test; browser coverage would add cost without validating additional business logic. +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/CanonicalViewRunLinksTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Filament/InventoryCoverageRunContinuityTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php tests/Feature/078/RelatedLinksOnDetailTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php` +- **Fixture / helper / factory / seed / context cost risks**: Minimal. Existing `OperationRun` factories, workspace membership helpers, tenant fixtures, and platform-user fixtures are sufficient. +- **Expensive defaults or shared helper growth introduced?**: No. The guard allowlist remains opt-in and local to this feature; no new global test helper is required. +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: Standard native-Filament relief plus the existing `monitoring-state-page` profile for canonical operations destinations +- **Closing validation and reviewer handoff**: Re-run `pint`, then the focused test command above. Reviewers should verify that each migrated source now uses the correct helper family, that remaining raw producers sit only in the named exception boundary, and that both admin and system authorization semantics are unchanged. +- **Budget / baseline / trend follow-up**: none +- **Review-stop questions**: Did the guard accidentally absorb tests or non-operator infrastructure? Did any admin-plane producer still hand-assemble `admin.operations` URLs? Did any system-plane producer start bypassing `SystemOperationRunLinks`? Did any exception remain convenience-based instead of infrastructure-based? +- **Escalation path**: `document-in-feature` +- **Active feature PR close-out entry**: `Guardrail` +- **Why no dedicated follow-up spec is needed**: This is current-release contract enforcement around an existing shared interaction family. Only a later desire for repo-wide routing policy would justify a separate governance spec. + +## Project Structure + +### Documentation (this feature) + +```text +specs/232-operation-run-link-contract/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── operation-run-link-contract.logical.openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/ +│ │ │ ├── InventoryCoverage.php +│ │ │ └── Operations/TenantlessOperationRunViewer.php +│ │ ├── Resources/ +│ │ │ ├── InventoryItemResource.php +│ │ │ └── ReviewPackResource.php +│ │ └── Widgets/Tenant/RecentOperationsSummary.php +│ ├── Http/Controllers/ClearTenantContextController.php +│ ├── Providers/Filament/ +│ │ ├── AdminPanelProvider.php +│ │ └── TenantPanelProvider.php +│ └── Support/ +│ ├── Middleware/EnsureFilamentTenantSelected.php +│ ├── Navigation/ +│ │ ├── CanonicalNavigationContext.php +│ │ └── RelatedNavigationResolver.php +│ ├── OperationRunLinks.php +│ ├── OpsUx/OperationRunUrl.php +│ └── System/SystemOperationRunLinks.php +└── tests/ + └── Feature/ + ├── 078/RelatedLinksOnDetailTest.php + ├── 144/CanonicalOperationViewerDeepLinkTrustTest.php + ├── Filament/ + │ ├── InventoryCoverageRunContinuityTest.php + │ └── RecentOperationsSummaryWidgetTest.php + ├── Guards/OperationRunLinkContractGuardTest.php + ├── Monitoring/OperationsDashboardDrillthroughTest.php + ├── OpsUx/CanonicalViewRunLinksTest.php + ├── ReviewPack/ReviewPackResourceTest.php + ├── RunAuthorizationTenantIsolationTest.php + └── System/ + ├── Spec113/AuthorizationSemanticsTest.php + └── Spec195/SystemDirectoryResidualSurfaceTest.php +``` + +**Structure Decision**: Single Laravel application inside the monorepo. Runtime changes stay inside `apps/platform`, while planning artifacts remain under `specs/232-operation-run-link-contract`. + +## Complexity Tracking + +No constitutional violation is planned. One bounded review artifact is tracked explicitly because the feature adds an allowlisted guard boundary. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| BLOAT-001 bounded exception inventory | The guard needs a small explicit exception list so bootstrapping and redirect code does not have to fake helper usage or fabricate runtime context it does not own. | A raw repo-wide ban without named exceptions would create false positives, blur the operator-facing boundary, and push the feature into heavy-governance scope. | + +## Proportionality Review + +- **Current operator problem**: Platform-owned admin and system surfaces still have parallel ways to open the same canonical operations destinations, which creates plane and scope drift and keeps the next contributor free to reintroduce raw route assembly. +- **Existing structure is insufficient because**: The helper families already exist, but they are optional in practice. Without a bounded enforcement slice, the repository keeps two competing link-generation paths. +- **Narrowest correct implementation**: Migrate the confirmed raw admin producers, preserve the existing system helper path, record the few legitimate infrastructure exceptions, and add one route-bounded guard that fails on new bypasses. +- **Ownership cost created**: Small ongoing maintenance of the exception list and targeted regression coverage for a shared interaction family. +- **Alternative intentionally rejected**: A repo-wide route-string ban or a new navigation presenter stack. Both would be broader than the current operator problem and would add governance or architecture cost the release does not need. +- **Release truth**: Current-release contract enforcement and cleanup. + +## Phase 0 Research Summary + +- Reuse `OperationRunLinks` for every admin-plane producer that opens `/admin/operations` or `/admin/operations/{run}`; do not create a second admin helper or presenter. +- Treat the currently confirmed raw admin-plane producers as first-slice migration targets: `RecentOperationsSummary`, `InventoryCoverage`, `InventoryItemResource`, `ReviewPackResource`, `TenantlessOperationRunViewer`, and `RelatedNavigationResolver`. +- Keep the initial explicit exception boundary narrow and infrastructure-only: panel providers, tenant-selection middleware, and clear-tenant-context redirects may stay raw if helper adoption would fabricate the wrong runtime context. +- Preserve `SystemOperationRunLinks` as the canonical system-plane path. Current system widgets and pages are already largely converged, so the first slice focuses on regression prevention rather than inventing synthetic migration work. +- Treat `OperationRunUrl` as an acceptable thin delegating seam because it forwards directly to `OperationRunLinks` and does not create parallel routing truth. +- Keep the guard route-bounded to platform-owned UI and shared navigation code under `apps/platform/app`; do not scan tests, helpers themselves, or unrelated infrastructure. + +## Phase 1 Design Summary + +- `data-model.md` documents the feature as a derived contract over existing `OperationRun`, tenant, workspace, and canonical navigation truth plus a bounded producer inventory and explicit exception model. +- `contracts/operation-run-link-contract.logical.openapi.yaml` defines the internal logical contract for canonical admin/system collection and detail links plus the bounded guard request and result shape. +- `quickstart.md` provides the focused validation path for representative admin drill-throughs, system-plane continuity, allowlisted exceptions, and authorization semantics. + +## Implementation Close-Out Inventory + +- **Migrated admin producers**: `RecentOperationsSummary` collection links now use `OperationRunLinks::index(...)`; `InventoryCoverage` basis/detail and inventory-sync history links now use `OperationRunLinks::view(...)` and helper-owned type filtering; `InventoryItemResource` and `ReviewPackResource` detail links now use `OperationRunLinks::view(...)` or `tenantlessView(...)`; `TenantlessOperationRunViewer` default collection fallbacks now use `OperationRunLinks::index()`; `RelatedNavigationResolver` operation-run audit target links now use `OperationRunLinks::tenantlessView(...)`. +- **Verified system producers**: `ViewTenant`, `ViewWorkspace`, `Runs`, and `ViewRun` remain on `SystemOperationRunLinks::index()` and `SystemOperationRunLinks::view(...)` with no admin-plane fallback. +- **Accepted delegate**: `App\Support\OpsUx\OperationRunUrl` remains a thin delegate to `OperationRunLinks` and is explicitly covered by helper-contract tests. +- **Allowlisted infrastructure exceptions**: `AdminPanelProvider`, `TenantPanelProvider`, `EnsureFilamentTenantSelected`, and `ClearTenantContextController` retain raw `admin.operations.index` routes because they own panel bootstrapping or redirect behavior rather than source-surface drill-through context. +- **Guard boundary**: `OperationRunLinkContractGuardTest` scans the migrated admin producers, verified system producers, accepted delegate, and four explicit infrastructure exceptions. It blocks raw `admin.operations.index`, raw `admin.operations.view`, direct `/system/ops/runs` paths, and direct `Runs::getUrl(...)` / `ViewRun::getUrl(...)` use outside the allowlist. +- **Test-governance disposition**: `document-in-feature`. The cost is contained to a focused feature guard and representative feature coverage; no follow-up spec or heavy-governance lane is needed. + +## Phase 1 Agent Context Update + +- Run `.specify/scripts/bash/update-agent-context.sh copilot` after the plan, research, data model, quickstart, and contract artifacts are written. +- The update must preserve manual additions between generated markers and add only the new technology and change notes relevant to Spec 232. +- The generated agent-context file is supporting output for the planning workflow, not a reason to widen the feature scope. + +## Implementation Strategy + +1. **Inventory and classify producers** + - Freeze the first-slice inventory of raw admin-plane link producers and distinguish between migration targets, verified helper-backed system producers, and explicit infrastructure exceptions. + +2. **Migrate direct admin collection and detail links** + - Replace raw `route('admin.operations.index')` and `route('admin.operations.view')` usage in widgets, pages, and resources with `OperationRunLinks` methods. + - Preserve canonical tenant continuity, problem-class filters, active-tab semantics, and current operator-facing labels through the helper contract only. + +3. **Normalize shared related-navigation and back-link paths** + - Refactor `RelatedNavigationResolver` and `TenantlessOperationRunViewer` to consume canonical helper methods rather than local route assembly. + - Keep back-link context helper-owned when `CanonicalNavigationContext` is present and degrade cleanly to the canonical admin collection when it is absent. + +4. **Retain only justified infrastructure exceptions** + - Keep panel navigation providers, tenant-selection middleware, and clear-tenant-context redirects as the narrow allowlisted exception set for this slice. + - Record each allowlisted exception with a reason tied to bootstrapping or redirect ownership, not convenience. + +5. **Protect the already-converged system plane** + - Audit verified helper-backed system pages and widgets and keep them on `SystemOperationRunLinks`, applying only minimal cleanup if a direct page URL slips through. + - Use the guard and authorization tests to prove that no platform-facing system producer regresses to admin-plane or direct page URL assembly. + +6. **Add bounded regression coverage** + - Add one guard test that scans only the declared app-side boundary and fails with file-plus-snippet output on representative bypasses. + - Extend or update focused feature tests so admin-plane continuity, explicit admin `404`/`403` preservation, system-plane continuity, and negative authorization behavior remain explicit. + +## Risks and Mitigations + +- **False-positive guard scope**: A broad scan would catch tests or infrastructure code. Mitigation: keep the boundary on platform-owned UI and shared navigation files only and maintain a file-scoped allowlist for legitimate exceptions. +- **Tenant continuity drift**: Replacing raw URLs could accidentally drop canonical filters or navigation context. Mitigation: route collection and detail links through existing helper parameters and keep representative continuity tests in scope. +- **Back-link regression on run detail**: `TenantlessOperationRunViewer` currently mixes raw fallbacks with helper-owned detail refresh behavior. Mitigation: migrate both the back-to-operations and show-all-operations fallbacks in the same slice so behavior stays coherent. +- **Over-scoping into routing cleanup**: It is easy to turn this into a general route-string purge. Mitigation: keep the feature limited to `OperationRun` collection and detail link producers plus their bounded exception list. + +## Post-Design Re-check + +Phase 0 and Phase 1 outputs resolve the planning questions without introducing a new routing framework, new persisted navigation truth, or a repo-wide governance lane change. The plan remains constitution-compliant, helper-first, and ready for `/speckit.tasks`. diff --git a/specs/232-operation-run-link-contract/quickstart.md b/specs/232-operation-run-link-contract/quickstart.md new file mode 100644 index 00000000..1d0fdb26 --- /dev/null +++ b/specs/232-operation-run-link-contract/quickstart.md @@ -0,0 +1,97 @@ +# Quickstart: Operation Run Link Contract Enforcement + +## Prerequisites + +1. Start the local platform stack. + + ```bash + export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" + cd apps/platform && ./vendor/bin/sail up -d + ``` + +2. Work with: + - one workspace operator who can access canonical admin monitoring, + - one entitled tenant with recent `OperationRun` records, + - one second tenant that the operator must not be able to inspect, and + - one platform user who can access `/system/ops/runs`. + +3. Remember that this feature changes link generation only. No frontend asset build should be required unless unrelated platform assets changed. + +## Automated Validation + +Run formatting and the narrowest proving suites for this feature: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" +cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/CanonicalViewRunLinksTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Filament/InventoryCoverageRunContinuityTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php tests/Feature/078/RelatedLinksOnDetailTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php +``` + +## Final Guard Boundary + +The implemented guard is bounded to the first-slice source surfaces and explicit infrastructure exceptions: + +- **Migrated admin producers**: `RecentOperationsSummary`, `InventoryCoverage`, `InventoryItemResource`, `ReviewPackResource`, `TenantlessOperationRunViewer`, and `RelatedNavigationResolver`. +- **Verified system producers**: `ViewTenant`, `ViewWorkspace`, `Runs`, and `ViewRun`, all continuing through `SystemOperationRunLinks`. +- **Accepted thin delegate**: `App\Support\OpsUx\OperationRunUrl`, which forwards to `OperationRunLinks`. +- **Allowlisted infrastructure exceptions**: `AdminPanelProvider`, `TenantPanelProvider`, `EnsureFilamentTenantSelected`, and `ClearTenantContextController`. +- **Forbidden bypasses inside the boundary**: raw `route('admin.operations.index')`, raw `route('admin.operations.view')`, direct `/system/ops/runs` strings, and direct `Runs::getUrl(...)` or `ViewRun::getUrl(...)` outside `SystemOperationRunLinks`. + +## Manual Validation Flow + +### 1. Validate tenant-aware admin collection continuity + +1. Open a tenant-facing surface that exposes the recent-operations summary or an inventory coverage follow-up link. +2. Follow the `Open operations` or equivalent history link. +3. Confirm the destination stays on `/admin/operations` and preserves only helper-supported tenant or filter continuity. +4. Confirm the page does not invent a tenant-prefixed duplicate operations route. + +### 2. Validate canonical admin detail links from representative resource surfaces + +1. Open one inventory item with a `last_seen_operation_run_id`. +2. Follow the `Last inventory sync` link. +3. Open one review pack with an associated `operation_run_id`. +4. Confirm both links open canonical admin run detail, not a surface-local route or raw fallback URL. + +### 3. Validate shared related-navigation and back-link behavior + +1. Open a surface that renders an `operation_run` related link through `RelatedNavigationResolver`. +2. Confirm the helper-generated label and URL match canonical admin run detail behavior. +3. Open `TenantlessOperationRunViewer` through a source without an explicit back-link context. +4. Confirm `Back to Operations` and `Show all operations` land on the canonical admin collection helper path. + +### 4. Validate system-plane continuity + +1. Open a system-plane widget or directory page with run drill-through. +2. Follow collection and detail links into monitoring. +3. Confirm the destination stays on `/system/ops/runs` or `/system/ops/runs/{run}` and does not fall back to `/admin/operations`. + +### 5. Validate authorization semantics stayed unchanged + +1. As a workspace member who is not entitled to a foreign tenant, request a canonical admin detail URL for that tenant’s run. +2. Confirm the response remains `404`. +3. As a non-platform user, request a system-plane operations URL. +4. Confirm the response remains `404`. +5. As an entitled actor missing the relevant capability, confirm current destination behavior still yields `403` where the route already distinguishes membership from capability denial. + +### 6. Validate the explicit exception boundary + +1. Confirm that navigation boot, middleware, and clear-tenant redirect behavior still function after the cleanup. +2. Review the named allowlist entries and verify each remaining raw producer is infrastructure-owned rather than convenience-owned. +3. Confirm no new operator-facing page, widget, or related-navigation builder remains on raw `admin.operations.*` assembly outside the allowlist. + +### 7. Validate the guardrail + +1. Use a temporary local probe or test fixture to simulate one representative raw `route('admin.operations.view', ...)` bypass inside the declared guard boundary without committing it. +2. Run the guard test. +3. Confirm it fails with actionable file and snippet output. +4. Replace the bypass with the canonical helper or move it into an explicitly justified exception and confirm the guard passes again. + +## Reviewer Notes + +- The feature stays Livewire v4.0+ compatible and does not change provider registration in `bootstrap/providers.php`. +- No new global-search surface is introduced; `InventoryItemResource` already has a view page and `ReviewPackResource` remains non-searchable. +- No destructive action or new asset behavior is introduced. +- The contract boundary is intentionally narrow: platform-owned UI and shared navigation code only. diff --git a/specs/232-operation-run-link-contract/research.md b/specs/232-operation-run-link-contract/research.md new file mode 100644 index 00000000..06d88268 --- /dev/null +++ b/specs/232-operation-run-link-contract/research.md @@ -0,0 +1,67 @@ +# Research: Operation Run Link Contract Enforcement + +## Decision 1: Reuse the existing helper families instead of introducing a new link presenter + +**Decision**: Treat `App\Support\OperationRunLinks` as the single admin-plane contract and `App\Support\System\SystemOperationRunLinks` as the single system-plane contract for all in-scope `OperationRun` collection and detail links. + +**Rationale**: The existing helper families already own canonical nouns, labels, plane separation, and admin query semantics. The current defect is incomplete adoption, not missing capability. Reusing those helpers satisfies XCUT-001 with the smallest possible change. + +**Alternatives considered**: + +- Create a new cross-plane navigation presenter or registry. Rejected because the repository already has concrete helper ownership for the exact routes this feature targets. +- Fix each raw producer locally without naming a shared contract. Rejected because it would not stop the next contributor from reintroducing raw route assembly. + +## Decision 2: Scope the first migration wave to the confirmed raw admin-plane producers + +**Decision**: The first implementation slice migrates the currently confirmed raw admin-plane producers in `RecentOperationsSummary`, `InventoryCoverage`, `InventoryItemResource`, `ReviewPackResource`, `TenantlessOperationRunViewer`, and `RelatedNavigationResolver`. + +**Rationale**: A targeted scan of `apps/platform/app` shows these files still assemble `admin.operations.index` or `admin.operations.view` links directly inside platform-owned UI or shared navigation code. They represent the smallest set of real producers needed to satisfy FR-232-003, FR-232-014, and FR-232-015. + +**Alternatives considered**: + +- Full opportunistic cleanup of every `admin.operations.*` string in the repository. Rejected because tests and legitimate infrastructure redirects would expand the slice into heavy-governance work. +- Migrate only one or two surfaces. Rejected because the drift is already spread across widget, page, resource, and shared-resolver layers. + +## Decision 3: Keep the initial allowlist narrow and infrastructure-only + +**Decision**: Seed the explicit exception boundary with bootstrapping or redirect-owned raw producers such as `AdminPanelProvider`, `TenantPanelProvider`, `EnsureFilamentTenantSelected`, and `ClearTenantContextController`, and require file-specific justification for each retained exception. + +**Rationale**: These producers sit in navigation boot, middleware, or redirect code where runtime canonical navigation context is not the owning abstraction. Forcing helper usage there can fabricate the wrong dependency shape or hide a deliberate redirect contract. + +**Alternatives considered**: + +- Ban all raw route usage with no exceptions. Rejected because it would create false positives in infrastructure code and push the feature into a broader governance lane. +- Allow convenience-based exceptions. Rejected because convenience is exactly what created the current drift. + +## Decision 4: Treat the system plane as contract lock-in, not broad migration work + +**Decision**: Preserve `SystemOperationRunLinks` as the canonical system-plane path and focus the system portion of the feature on regression protection and authorization proof rather than manufactured cleanup work. + +**Rationale**: The app-side scan shows current system widgets and pages already use `SystemOperationRunLinks` consistently. The feature should document and protect that convergence instead of inflating the slice with unnecessary system refactors. + +**Alternatives considered**: + +- Expand the scope into general system routing cleanup. Rejected because the current-release problem is not missing on the system plane in app code. +- Ignore the system plane entirely. Rejected because FR-232-002, FR-232-008, and FR-232-016 still require explicit system-plane continuity protection. + +## Decision 5: The guard stays route-bounded and app-side only + +**Decision**: The new guard scans only the declared platform-owned UI and shared-navigation boundary in `apps/platform/app` and fails on raw `admin.operations.*` route assembly or direct system operations page URLs outside the explicit allowlist. + +**Rationale**: The proving purpose is preventing new operator-facing bypasses, not banning route literals everywhere in the repository. A route-bounded guard keeps the feature in `fast-feedback + confidence` instead of escalating it into a structural repo-governance family. + +**Alternatives considered**: + +- Whole-repo scanning including tests and unrelated infrastructure. Rejected because it would create noisy failures and require a much broader allowlist. +- No guard at all. Rejected because the cleanup would then be a one-time repair with no contract enforcement. + +## Decision 6: Keep thin delegating wrappers out of the violation set + +**Decision**: Treat `App\Support\OpsUx\OperationRunUrl` as an acceptable thin seam because it delegates directly to `OperationRunLinks` and does not introduce parallel route truth. + +**Rationale**: The spec targets raw bypass of the canonical helper families, not every intermediate call site that already converges on them. Counting a delegating wrapper as a violation would add churn without improving contract safety. + +**Alternatives considered**: + +- Force all wrapper users to switch to direct helper calls in the same slice. Rejected because the wrapper is not the source of drift and does not block enforcement of the raw-route contract. +- Allow arbitrary wrappers. Rejected because only pure delegating seams remain acceptable; a wrapper that adds its own routing logic would reintroduce the same problem. \ No newline at end of file diff --git a/specs/232-operation-run-link-contract/spec.md b/specs/232-operation-run-link-contract/spec.md new file mode 100644 index 00000000..4d8df75f --- /dev/null +++ b/specs/232-operation-run-link-contract/spec.md @@ -0,0 +1,302 @@ +# Feature Specification: Operation Run Link Contract Enforcement + +**Feature Branch**: `232-operation-run-link-contract` +**Created**: 2026-04-23 +**Status**: Draft +**Input**: User description: "schau in spec-candidate und roadmap rein und wähle das nächste sinnvolle spec" + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: Platform-owned admin and system surfaces still emit `OperationRun` collection and detail links through raw route assembly instead of the shared helper families, so the same canonical operations destinations have two parallel generation paths. +- **Today's failure**: Operators can reach the right pages through URLs that ignore tenant-prefilter or plane semantics, while contributors can keep adding raw operation routes in shared UI code without tripping a contract failure. +- **User-visible improvement**: Operation links behave consistently across dashboards, resources, monitoring pages, and shared related-navigation so operators land in the canonical destination with the expected plane and scope continuity. +- **Smallest enterprise-capable version**: Inventory all platform-owned operation collection/detail link producers, migrate raw admin-plane producers to `OperationRunLinks`, verify already-helper-backed system producers stay on `SystemOperationRunLinks`, record the narrow allowlisted exceptions, and add one automated guard that blocks new raw bypasses. +- **Explicit non-goals**: No operations IA redesign, no page-content rewrite, no status-semantics change, no new tenant-specific operations route family, and no platform-wide routing cleanup beyond `OperationRun` collection/detail links. +- **Permanent complexity imported**: One explicit allowlist for legitimate infrastructure-only exceptions, narrow regression coverage for admin-plane and system-plane link producers, and small helper-contract extensions only if current canonical context parameters are incomplete. +- **Why now**: Governance & Architecture Hardening is the active roadmap block, and the 2026-04-22 drift audit identified this as the first high-leverage contract-enforcement slice before more operations surfaces or navigation retrofits land. +- **Why not local**: The drift exists across shared navigation and multiple platform-owned surfaces. A one-off fix on one widget or resource would leave the same contract bypass available everywhere else. +- **Approval class**: Cleanup +- **Red flags triggered**: Cross-cutting interaction-class scope and repository guardrail addition. Defense: the helper families already exist, the feature only makes them mandatory on platform-owned paths, and the guard stays bounded to explicit allowlisted exceptions instead of introducing a new framework. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 2 | Produktnähe: 1 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace + canonical-view + system +- **Primary Routes**: + - `/admin/operations` + - `/admin/operations/{run}` + - `/system/ops/runs` + - `/system/ops/runs/{run}` + - Existing platform-owned source surfaces that drill into those canonical destinations, including tenant recency widgets, inventory coverage follow-up links, review-pack operation links, panel navigation shortcuts, and shared related-navigation builders +- **Data Ownership**: + - No new persisted entity or ownership model is introduced. + - `operation_runs` remain the existing operational source of truth, tenant-owned in the admin plane with workspace-scoped canonical viewing rules and platform-plane visibility through existing system monitoring surfaces. + - Workspace- or system-page filter state remains derived navigation state only; this feature does not persist copied link state or introduce a second run-navigation record. +- **RBAC**: + - Admin-plane monitoring links continue to require workspace membership; tenant-linked destinations continue to enforce tenant entitlement and existing operations-view capabilities server-side. + - System-plane monitoring links continue to require authenticated platform users with existing system operations access. + - Cross-plane access remains deny-as-not-found; this feature does not introduce new capabilities or raw capability strings. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: Admin-plane collection links may preserve the active entitled tenant through the canonical `OperationRunLinks` collection contract. Admin-plane run detail remains record-authoritative and may carry canonical navigation context, but must not invent a tenant-specific duplicate detail route. System-plane links never inherit tenant-context filters from `/admin`. +- **Explicit entitlement checks preventing cross-tenant leakage**: Helper-generated links may carry only entitled tenant/query context; destination pages re-check workspace membership, tenant entitlement, and plane-specific access before rendering. A link from a covered surface must never make a foreign-tenant run or system route newly observable. + +## 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 +- **Interaction class(es)**: navigation, action links, related links, deep links +- **Systems touched**: admin monitoring surfaces, system operations surfaces, shared related-navigation builders, panel shortcuts, and tenant/workspace drill-through links from recency and evidence-oriented surfaces +- **Existing pattern(s) to extend**: canonical operation link helpers, canonical navigation context propagation, and existing operations row-click/detail conventions +- **Shared contract / presenter / builder / renderer to reuse**: `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, and `App\Support\Navigation\CanonicalNavigationContext` +- **Why the existing shared path is sufficient or insufficient**: The shared path is already sufficient for collection/detail labels, tenant-prefilter continuity, active-tab/problem-class query semantics, and plane separation. The current gap is not missing capability but incomplete adoption where raw `route('admin.operations...')` or direct system page URLs still bypass the contract. +- **Allowed deviation and why**: Bounded infrastructure-only exceptions may remain when the producer cannot depend on the helper family without introducing the wrong runtime dependency or hiding a deliberate redirect contract. Every retained exception must be explicit and allowlisted. +- **Consistency impact**: `Operations` / `Operation` nouns, `Open operations` / `Open operation` labels, admin versus system plane routing, tenant-prefilter continuity, canonical back-link behavior, and shared related-link structure must stay aligned across all covered producers. +- **Review focus**: Reviewers must verify that new or changed platform-owned operation links use the correct helper family for their plane and that no new raw collection/detail route assembly appears outside the explicit allowlist. + +## 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 +not change an operator-facing surface, write `N/A - no operator-facing surface +change` here and do not invent duplicate prose in the downstream surface tables. + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Admin monitoring collection/detail link contract enforcement | yes | Native Filament + shared helpers | navigation / related links | table, detail header, linked widgets | no | Destination pages stay the same; only link generation is standardized | +| System operations collection/detail link contract enforcement | yes | Native Filament + shared helpers | navigation / related links | table, detail header | no | Destination pages stay the same; plane separation becomes explicit | +| Shared operation drill-through links from tenant/workspace surfaces | yes | Native Filament widgets/resources + shared helpers | navigation | widget, table cell, infolist/detail supporting links | no | Low-impact UI change; same destinations, stricter continuity semantics | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +If this feature adds or materially changes an operator-facing surface, +fill out one row per affected surface. This role is orthogonal to the +Action Surface Class / Surface Type below. Reuse the exact surface names +and classifications from the UI / Surface Guardrail Impact section above. + +| 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 | +|---|---|---|---|---|---|---|---| +| Admin monitoring collection and run detail | Secondary Context Surface | Open canonical monitoring after a tenant/workspace surface signals follow-up | Correct destination family, preserved tenant scope when valid, stable canonical operation identity | Full run diagnostics, raw context, related artifacts | Secondary because the decision to inspect happens on the source surface; monitoring remains the canonical context surface for follow-up and diagnosis | Follows existing dashboard/resource-to-monitoring workflow instead of creating duplicate run pages | Removes operator guesswork about which operations page and which scope a link should open | +| System operations collection and run detail | Secondary Context Surface | Open platform-plane run monitoring from system runbooks or system registries | Correct system destination, preserved platform plane, stable run identity | Full system run diagnostics and runbook context | Secondary because it supports platform triage after a runbook or system list already framed the operator task | Follows existing `/system` triage workflow and keeps system links out of `/admin` | Removes plane confusion and manual route reconstruction for platform operators | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface, +fill out one row per affected surface. Declare the broad Action Surface +Class first, then the detailed Surface Type. Keep this table in sync +with the Decision-First Surface Role section above and avoid renaming the +same surface a second time. + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Admin monitoring collection | Read-only Registry / Report | Canonical monitoring registry | Open operation detail or inspect filtered run history | Full-row click or canonical helper-generated open link | required | Header/context filters and source-surface link affordances only | No new destructive placement; existing detail-only interventions remain unchanged | `/admin/operations` | `/admin/operations/{run}` | Workspace scope, optional entitled tenant filter, canonical navigation context | Operations / Operation | The operator can tell they are entering canonical admin monitoring and whether tenant context was preserved | none | +| Admin operation run detail | Canonical detail | Diagnostic run detail | Inspect one run without losing canonical plane semantics | Direct route-resolved detail page | forbidden | Related links and back-navigation remain secondary in the header/body | No new destructive actions in this feature | `/admin/operations` | `/admin/operations/{run}` | Workspace scope, entitled tenant context if present, run identity | Operations / Operation | The canonical run identity and plane are obvious without interpreting source-surface hacks | none | +| System operations collection | Read-only Registry / Report | Platform monitoring registry | Open system run detail | Full-row click or canonical helper-generated open link | required | Header filters or runbook follow-up only | Existing system interventions remain separately governed | `/system/ops/runs` | `/system/ops/runs/{run}` | Platform scope only | Operations / Operation | The operator can tell they are staying in the system plane | none | +| System run detail | Detail / Decision | Platform run triage detail | Inspect one platform run in the correct plane | Direct route-resolved detail page | forbidden | Header/context links only | Existing system-detail interventions remain separately governed | `/system/ops/runs` | `/system/ops/runs/{run}` | Platform scope, run identity, system context | Operations / Operation | The canonical system-run destination is explicit and not silently downgraded to admin monitoring | none | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +If this feature adds a new operator-facing page or materially refactors +one, fill out one row per affected page/surface. The contract MUST show +how one governance case or operator task becomes decidable without +unnecessary cross-page reconstruction. + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Admin monitoring collection and run detail | Workspace operator | Follow a source-surface signal into canonical operation history or one canonical run detail | Registry + detail | Did I land on the right canonical operations surface with the right scope, and can I inspect the run from here? | Canonical collection/detail destination, run identity, workspace scope, entitled tenant continuity where applicable | Raw context payloads, error traces, and extended related links | lifecycle, outcome, problem class, scope continuity | No new mutation in this slice | Open operation, Open operations | None added by this feature | +| System operations collection and run detail | Platform operator | Follow runbook or system registry context into platform-plane run history or one system run detail | Registry + detail | Am I staying in the system plane, and am I opening the correct run history/detail surface? | Canonical system destination, run identity, platform scope | Raw run context, failure payloads, runbook lineage | lifecycle, outcome, platform scope | No new mutation in this slice | Open operation, Open operations | None added by this feature | + +## 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?**: no +- **Current operator problem**: Covered surfaces can bypass the existing canonical operation-link contract, which makes plane and scope continuity drift across the same operations destinations. +- **Existing structure is insufficient because**: The helpers exist but are not enforced. Without one explicit enforcement slice and guardrail, every new platform-owned surface can keep choosing local route assembly. +- **Narrowest correct implementation**: Reuse the existing helper families, migrate all in-scope producers, and add one bounded allowlist guard instead of inventing a broader routing framework or new navigation model. +- **Ownership cost**: Small ongoing allowlist review, targeted regression maintenance, and occasional helper extensions when new canonical query semantics are introduced. +- **Alternative intentionally rejected**: A one-off cleanup of currently known raw routes without a guardrail was rejected because it would not stop the same drift from reappearing on the next surface. +- **Release truth**: current-release contract enforcement and cleanup, not future-platform preparation + +### 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)* + +For docs-only or template-only changes, state concise `N/A` or `none`. For runtime- or test-affecting work, classification MUST follow the proving purpose of the change rather than the file path or folder name. + +- **Test purpose / classification**: Feature +- **Validation lane(s)**: fast-feedback + confidence +- **Why this classification and these lanes are sufficient**: This change is proved by end-to-end URL generation and plane/scope outcomes on real admin/system surfaces, not by isolated string helpers alone. Fast-feedback covers representative admin/system surfaces and the guard. Confidence reruns the broader operations link-contract coverage already present in the monitoring family. +- **New or expanded test families**: Extend existing canonical run-link coverage for admin and system surfaces; add one focused guard test that blocks new raw platform-owned operation routes outside the allowlist. +- **Fixture / helper cost impact**: Minimal. Existing `OperationRun` factories, workspace/tenant membership helpers, and platform-user fixtures are sufficient. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: monitoring-state-page +- **Standard-native relief or required special coverage**: Requires shared-link contract coverage on representative widgets/resources plus one repository guard check; no browser or heavy-governance suite is needed. +- **Reviewer handoff**: Reviewers must confirm that each migrated producer emits helper-generated URLs for the correct plane, that allowlisted exceptions are explicitly justified, and that the guard pattern cannot be trivially bypassed. +- **Budget / baseline / trend impact**: none +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/CanonicalViewRunLinksTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Filament/InventoryCoverageRunContinuityTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php tests/Feature/078/RelatedLinksOnDetailTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Follow Admin Operations Links Consistently (Priority: P1) + +As a workspace operator, I want platform-owned admin surfaces to open canonical operations collection/detail links through one shared contract so that tenant-prefilter and run-detail continuity are consistent. + +**Why this priority**: This is the direct operator-facing path. If admin-plane drill-through links remain inconsistent, the canonical monitoring experience stays fragile even when the destination pages themselves are correct. + +**Independent Test**: Can be fully tested by opening operations links from covered tenant/workspace surfaces such as recency widgets, inventory coverage, and review-pack detail and confirming that the links land on the canonical admin collection/detail destinations with the expected scope continuity. + +**Acceptance Scenarios**: + +1. **Given** an active entitled tenant context, **When** an operator opens operations from a covered admin-plane surface, **Then** the link resolves to `/admin/operations` or `/admin/operations/{run}` with the expected tenant/context continuity and without inventing a duplicate route family. +2. **Given** no tenant context is active, **When** a covered admin-plane surface links to operations, **Then** it opens workspace-wide admin monitoring rather than inventing or leaking tenant scope. +3. **Given** a run belongs to a tenant the actor is not entitled to inspect, **When** the actor requests the destination, **Then** the destination still resolves as deny-as-not-found. + +--- + +### User Story 2 - Keep System-Plane Run Links in the System Plane (Priority: P1) + +As a platform operator, I want system run lists and runbooks to open canonical system operations URLs so that platform monitoring never silently routes me through the admin plane. + +**Why this priority**: Plane correctness is a core trust requirement. A system operator following a run link to the wrong plane is both confusing and authorization-sensitive. + +**Independent Test**: Can be fully tested by opening run history and run detail from covered `/system` surfaces and confirming that the destination remains `/system/ops/runs` or `/system/ops/runs/{run}` for platform users while tenant/admin users still cannot access those routes. + +**Acceptance Scenarios**: + +1. **Given** a system-plane list or follow-up link, **When** the platform operator opens a run, **Then** the URL is `/system/ops/runs/{run}` rather than an admin-plane monitoring route. +2. **Given** a system-plane collection link, **When** the platform operator opens history, **Then** the URL is `/system/ops/runs`. +3. **Given** a tenant/admin user session, **When** that user requests a system-plane destination, **Then** the response remains deny-as-not-found. + +--- + +### User Story 3 - Prevent New Raw Operation-Link Bypasses (Priority: P2) + +As a maintainer, I want a guardrail that fails when platform-owned UI introduces new raw operation routes outside allowlisted exceptions so that the contract stays enforced after cleanup. + +**Why this priority**: The cleanup only holds if the next contributor cannot quietly reintroduce the same drift on a new surface. + +**Independent Test**: Can be fully tested by introducing a representative raw route bypass in a covered area, observing the guard fail, then moving the same producer to the helper family or explicit allowlist and observing the guard pass. + +**Acceptance Scenarios**: + +1. **Given** a new platform-owned UI producer assembles `route('admin.operations...')` or direct system page URLs outside the allowlist, **When** the guard runs, **Then** the build fails with an actionable message. +2. **Given** an infrastructure-only exception is explicitly allowlisted, **When** the guard runs, **Then** it passes without forcing fake helper usage. +3. **Given** a new covered surface needs an additional canonical query semantic, **When** the contributor extends the helper family, **Then** the surface adopts the helper rather than adding a second local route pattern. + +### Edge Cases + +- A source surface has stale or absent tenant context; the admin collection link must degrade to workspace-wide canonical monitoring rather than carrying invalid tenant state. +- A source surface wants to prefilter by problem class or active tab; query semantics must be emitted through the helper contract only, not handwritten per surface. +- A run detail link is built from a tenant surface but the run belongs to a tenant the current actor can no longer access; opening the destination must still fail as `404`. +- A boot-time panel item or controller redirect cannot safely depend on view-context objects; if it remains raw, it must be explicitly allowlisted and justified. +- System-plane and admin-plane links for the same run ID must never resolve through the wrong plane by helper accident or local override. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature introduces no Microsoft Graph calls, no new mutation workflow, and no new `OperationRun` type. It standardizes link generation to already-shipped canonical operations destinations and must therefore make tenant isolation, plane separation, shared-link reuse, and regression coverage explicit. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature does not introduce new persistence, a new abstraction family, or new states. It deliberately reuses the existing helper families and adds only the narrowest enforcement mechanism needed to stop repeated drift on a current-release operator path. + +**Constitution alignment (XCUT-001):** This is a cross-cutting navigation and action-link feature. The shared path is `OperationRunLinks` for the admin plane and `SystemOperationRunLinks` for the system plane, with `CanonicalNavigationContext` carrying canonical admin query state where needed. Any retained raw-route exception must be explicit, justified, and reviewable. + +**Constitution alignment (TEST-GOV-001):** The proving purpose is runtime link behavior on real admin/system surfaces plus one repository guard. Feature tests and a focused guard are the narrowest sufficient proof. Existing factories and membership fixtures remain enough, and no heavy-governance or browser family is justified. + +**Constitution alignment (OPS-UX):** Not applicable. This feature does not create, start, or mutate `OperationRun` records. Existing operations destinations remain the canonical Ops-UX surfaces and keep their current service-owned lifecycle semantics. + +**Constitution alignment (RBAC-UX):** The feature spans the admin `/admin` plane and the platform `/system` plane. Cross-plane access remains `404`. Admin-plane destinations continue to enforce workspace membership first and tenant entitlement when the run is tenant-bound. System-plane destinations continue to enforce platform-user access only. The feature must add at least one positive and one negative authorization-path regression covering the affected link destinations. + +**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior changes. + +**Constitution alignment (BADGE-001):** Not applicable. No badge semantics are introduced or changed. + +**Constitution alignment (UI-FIL-001):** The affected surfaces remain native Filament pages, widgets, and resources. No local replacement markup is needed. Semantic emphasis stays in existing tables, related-link areas, and header affordances while the destination URLs are delegated to the shared helper families. + +**Constitution alignment (UI-NAMING-001):** The target object is the canonical operation destination. Operator verbs remain `Open operations`, `Open operation`, and `View in Operations` where those labels are already defined by the helper family. Source/domain disambiguation is only plane-based: admin-plane links open admin monitoring, system-plane links open system monitoring. + +**Constitution alignment (DECIDE-001):** The affected surfaces are Secondary Context Surfaces. Their responsibility is not to create a new decision queue but to preserve a calm, predictable path from source surfaces into canonical monitoring without forcing operators to reconstruct plane or scope by hand. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** This feature does not add new visible row or header actions. It preserves one primary inspect/open model per affected surface, keeps pure navigation in row click or supporting related links, leaves destructive actions where existing destinations already govern them, and does not introduce a second operations route family. + +**Constitution alignment (ACTSURF-001 - action hierarchy):** No new action hierarchy is introduced. Navigation remains separate from mutation, existing detail-only interventions remain where they are, and the feature does not turn route cleanup into a new action chrome pattern. + +**Constitution alignment (OPSURF-001):** The default-visible operator experience remains operator-first because the change is to destination continuity, not content density. Raw route tokens and local path assembly must not leak into surface-specific logic. Diagnostics remain on the destination detail pages, not in the link producers. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** No new UI-semantic or presenter layer is introduced. Direct helper reuse is preferred over another interpretation layer, and tests focus on business consequences: correct plane, correct destination, correct scope continuity, and blocked drift. + +**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. Each affected surface keeps exactly one primary inspect/open model, redundant `View` actions are not introduced by this feature, empty placeholder groups remain forbidden, and destructive actions remain governed by the existing destination pages. `UI-FIL-001` is satisfied with no exception. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** Existing operations pages, widgets, and resources keep their current layouts, infolists, table search/sort/filter behavior, and empty-state structure. No layout exemption is needed because this slice changes destination generation only. + +### Functional Requirements + +- **FR-232-001**: The system MUST treat `App\Support\OperationRunLinks` as the canonical admin-plane generator for platform-owned links to `/admin/operations` and `/admin/operations/{run}`. +- **FR-232-002**: The system MUST treat `App\Support\System\SystemOperationRunLinks` as the canonical system-plane generator for platform-owned links to `/system/ops/runs` and `/system/ops/runs/{run}`. +- **FR-232-003**: The first implementation slice MUST inventory all platform-owned collection/detail link producers for `OperationRun` destinations and classify each producer as migrated, verified helper-backed, or explicit exception. +- **FR-232-004**: Covered admin-plane producers MUST stop assembling raw `route('admin.operations.index')` or `route('admin.operations.view')` URLs locally. +- **FR-232-005**: Covered system-plane producers MUST remain on `SystemOperationRunLinks`, and any direct system operations page URL assembly outside explicit allowlisted infrastructure-only cases MUST be removed. +- **FR-232-006**: Admin-plane collection links originating from tenant-aware surfaces MUST preserve only valid canonical context supported by `OperationRunLinks`, including entitled tenant prefilter, active tab, problem class, and canonical navigation context when applicable. +- **FR-232-007**: Admin-plane run-detail links MUST use the canonical admin detail helper and MUST NOT invent tenant-prefixed or surface-specific duplicate detail routes. +- **FR-232-008**: System-plane links MUST never route platform operators to admin-plane monitoring pages as the default destination for system operations history or detail. +- **FR-232-009**: The helper contract MUST remain the only source for the canonical operator-facing nouns and open labels used by covered admin-plane operation links. +- **FR-232-010**: Existing admin-plane and system-plane destination pages MUST keep their current authorization and plane semantics; this feature MUST NOT widen access or weaken `404` versus `403` behavior. +- **FR-232-011**: Explicit exceptions to helper-family reuse MUST be documented, narrowly scoped, and justified by infrastructure or bootstrapping constraints rather than convenience. +- **FR-232-012**: The repository MUST provide one automated guard that fails when new platform-owned operation collection/detail links bypass the canonical helper families outside the explicit allowlist. +- **FR-232-013**: The guard MUST target platform-owned UI and shared navigation layers only and MUST NOT force helper use inside the helper classes themselves, unrelated tests, or non-operator infrastructure code outside the declared boundary. +- **FR-232-014**: Shared navigation builders that produce `operation_run` links, including audit- or related-navigation paths in scope, MUST use the canonical helper family for the destination plane instead of local route assembly. +- **FR-232-015**: Representative tenant/workspace source surfaces in scope, including at least one tenant recency surface, one evidence- or review-oriented surface, and one shared resolver path, MUST be migrated in the first rollout. +- **FR-232-016**: Regression coverage MUST prove correct admin-plane destination continuity, correct system-plane destination continuity, and guard failure on a representative bypass. +- **FR-232-017**: The feature MUST NOT introduce a new operations collection/detail route family, a second link presenter stack, or a separate tenant-local operations page. + +## UI Action Matrix *(mandatory when Filament is changed)* + +If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below. + +For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?), +RBAC gating (capability + enforcement helper), whether the mutation writes an audit log, and any exemption or exception used. + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Admin Operations index | `apps/platform/app/Filament/Pages/Monitoring/Operations.php` | Existing filter/context actions only | Full-row click and helper-generated incoming links to the canonical list | none added by this feature | none added by this feature | Existing clear-filter or empty-state CTA unchanged | n/a | n/a | no new audit behavior | Link contract only; no redundant `View` action added | +| Admin operation run detail | `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Existing back/refresh/context actions only | Direct route-resolved detail plus helper-generated incoming links | none added by this feature | none | n/a | Existing header navigation and related links only | n/a | no new audit behavior | Incoming links and related links must use the canonical admin helper | +| System operations runs | `apps/platform/app/Filament/System/Pages/Ops/Runs.php` | Existing filters or context actions only | Full-row click and helper-generated incoming links | none added by this feature | none added by this feature | Existing clear-filter CTA unchanged | n/a | n/a | no new audit behavior | Collection destination must stay in system plane | +| System operation run detail | `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php` | Existing refresh/contextual actions only | Direct route-resolved detail plus helper-generated incoming links | none added by this feature | none | n/a | Existing detail actions unchanged | n/a | no new audit behavior | Incoming links must use the canonical system helper | + +### Key Entities *(include if feature involves data)* + +- **Admin Operation Link Contract**: The canonical helper-generated admin monitoring destination, including collection/detail route choice and any allowed tenant- or navigation-context query semantics. +- **System Operation Link Contract**: The canonical helper-generated system monitoring destination for platform-plane run history and run detail. +- **Allowlisted Link Producer Exception**: A narrowly justified infrastructure-only producer that is explicitly permitted to bypass the helper family without becoming a general precedent for platform-owned UI code. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-232-001**: In automated regression coverage, 100% of covered admin-plane operation links open the canonical admin collection or detail destination with the expected tenant/workspace continuity. +- **SC-232-002**: In automated regression coverage, 100% of covered system-plane operation links open `/system/ops/runs` or `/system/ops/runs/{run}`, and 0 covered system links fall back to admin-plane monitoring. +- **SC-232-003**: The guardrail fails on a representative raw-route bypass and passes only for explicitly allowlisted exceptions in automated review coverage. +- **SC-232-004**: Negative authorization coverage confirms that covered links do not convert foreign-tenant or wrong-plane destinations into successful opens for unauthorized actors. + +## Assumptions + +- `OperationRunLinks` and `SystemOperationRunLinks` remain the chosen canonical helper families for this release rather than being replaced by a broader navigation framework. +- Existing admin-plane and system-plane operations destination pages already enforce the correct authorization and scope semantics; this feature fixes link production, not destination legitimacy. +- The known drift is concentrated in platform-owned UI and shared navigation layers rather than external integrations or public API contracts. + +## Non-Goals + +- Redesigning operations list/detail layout, action hierarchy, or lifecycle semantics +- Reworking failures or stuck route families into a broader system monitoring link framework beyond the canonical runs collection/detail seam +- Cleaning every historical test literal or non-operator infrastructure route string unrelated to platform-owned UI or shared navigation layers diff --git a/specs/232-operation-run-link-contract/tasks.md b/specs/232-operation-run-link-contract/tasks.md new file mode 100644 index 00000000..79854c12 --- /dev/null +++ b/specs/232-operation-run-link-contract/tasks.md @@ -0,0 +1,228 @@ +# Tasks: Operation Run Link Contract Enforcement + +**Input**: Design documents from `/specs/232-operation-run-link-contract/` +**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/operation-run-link-contract.logical.openapi.yaml`, `quickstart.md` + +**Tests**: Required. This feature changes runtime behavior in operator-facing monitoring drill-through and shared link-contract enforcement, so Pest coverage must be added or updated in `apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php`, `apps/platform/tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php`, `apps/platform/tests/Feature/078/RelatedLinksOnDetailTest.php`, `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`, `apps/platform/tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php`, `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php`. +**Operations**: No new `OperationRun` is introduced. Existing admin and system monitoring pages remain the canonical destination surfaces, and this feature must not change run lifecycle, notification, or audit semantics. +**RBAC**: The feature spans the admin `/admin` plane and the platform `/system` plane. It must preserve non-member or wrong-plane `404`, in-scope missing-capability `403`, tenant-safe canonical admin continuity, and current platform-only system access semantics. +**UI / Surface Guardrails**: The changed surfaces are native Filament widgets, pages, resources, and shared navigation builders. The admin monitoring entry points keep the `monitoring-state-page` profile, the remaining surfaces take `standard-native-filament` relief, and the repository signal is `review-mandatory` because the feature adds a bounded guard plus explicit exceptions. +**Filament UI Action Surfaces**: `RecentOperationsSummary`, `InventoryCoverage`, `InventoryItemResource`, `ReviewPackResource`, `TenantlessOperationRunViewer`, and the residual system directory pages keep their existing inspect/open model. No new header, row, bulk, or destructive actions are introduced. +**Badges**: Existing status and outcome badge semantics remain authoritative. This feature must not add ad-hoc badge mappings or a new status taxonomy. + +**Organization**: Tasks are grouped by user story so each slice remains independently testable after the shared helper and guard boundary are stabilized. Recommended delivery order is `US1 -> US2 -> US3` because the admin-plane cleanup is the primary migration slice and the guard should close only after the final migrated surface set and exceptions are settled. + +## 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 (Shared Link Contract Scaffolding) + +**Purpose**: Prepare the focused regression surfaces that will prove canonical admin and system link behavior before runtime files are edited. + +- [X] T001 [P] Extend baseline helper contract coverage for canonical admin and system collection/detail URLs in `apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` +- [X] T002 [P] Extend tenant-summary and dashboard drill-through coverage for canonical admin collection links in `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php` and `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php` +- [X] T003 [P] Extend resource-level admin detail continuity coverage in `apps/platform/tests/Feature/Filament/InventoryCoverageRunContinuityTest.php` and `apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php` +- [X] T004 [P] Extend shared resolver, canonical viewer, and system-plane continuity scaffolding in `apps/platform/tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php`, `apps/platform/tests/Feature/078/RelatedLinksOnDetailTest.php`, `apps/platform/tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php`, and `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php` + +**Checkpoint**: The focused test surfaces are ready to prove canonical helper adoption, system-plane continuity, and bounded guard behavior. + +--- + +## Phase 2: Foundational (Blocking Helper And Guard Boundary) + +**Purpose**: Stabilize the canonical helper contract and the route-bounded guard before any user story migration begins. + +**Critical**: No user story work should begin until this phase is complete. + +- [X] T005 Freeze canonical helper inputs, labels, and accepted delegation boundaries in `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/System/SystemOperationRunLinks.php`, and `apps/platform/app/Support/OpsUx/OperationRunUrl.php` +- [X] T006 [P] Create the bounded raw-bypass guard with scoped include paths, explicit exception candidates, forbidden patterns, and actionable file-plus-snippet output in `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php` + +**Checkpoint**: Canonical helper semantics and the initial guard boundary are stable enough for surface-by-surface migration work. + +--- + +## Phase 3: User Story 1 - Follow Admin Operations Links Consistently (Priority: P1) + +**Goal**: Platform-owned admin surfaces open canonical operations collection and detail URLs through `OperationRunLinks` with the correct tenant and navigation continuity. + +**Independent Test**: Open operations links from the tenant summary widget, dashboard drill-throughs, inventory coverage, review packs, and related-link surfaces, then confirm they land on `/admin/operations` or `/admin/operations/{run}` with only helper-supported continuity semantics. + +### Tests for User Story 1 + +- [X] T007 [P] [US1] Add tenant-aware admin collection link assertions for summary and dashboard sources in `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php` and `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php` +- [X] T008 [P] [US1] Add canonical admin detail link assertions for coverage and review-pack sources in `apps/platform/tests/Feature/Filament/InventoryCoverageRunContinuityTest.php` and `apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php` +- [X] T009 [P] [US1] Add canonical related-link, viewer fallback, and explicit admin `404`/`403` authorization assertions, including the in-scope capability-denial proof, in `apps/platform/tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php`, `apps/platform/tests/Feature/078/RelatedLinksOnDetailTest.php`, and `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php` + +### Implementation for User Story 1 + +- [X] T010 [P] [US1] Migrate admin collection links in `apps/platform/app/Filament/Widgets/Tenant/RecentOperationsSummary.php` and the collection/detail continuity paths in `apps/platform/app/Filament/Pages/InventoryCoverage.php` to `OperationRunLinks` +- [X] T011 [P] [US1] Migrate admin detail links in `apps/platform/app/Filament/Resources/InventoryItemResource.php` and `apps/platform/app/Filament/Resources/ReviewPackResource.php` to `OperationRunLinks::view(...)` +- [X] T012 [US1] Migrate shared related-navigation and canonical viewer fallback paths in `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php` and `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` to helper-owned admin links +- [X] T013 [US1] Run the US1 admin continuity verification flow documented in `specs/232-operation-run-link-contract/quickstart.md` + +**Checkpoint**: User Story 1 is independently functional and platform-owned admin drill-throughs consistently use the canonical admin helper family. + +--- + +## Phase 4: User Story 2 - Keep System-Plane Run Links In The System Plane (Priority: P1) + +**Goal**: Platform operators keep landing on canonical `/system/ops/runs` surfaces, and system follow-up links do not regress to admin-plane monitoring. + +**Independent Test**: Open system directory or operations follow-up links as a platform user and confirm collection/detail URLs remain helper-owned system-plane destinations while wrong-plane access still resolves as deny-as-not-found. + +### Tests for User Story 2 + +- [X] T014 [P] [US2] Add system-plane continuity and platform-authorization assertions in `apps/platform/tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php` and `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php` + +### Implementation for User Story 2 + +- [X] T015 [P] [US2] Audit system directory follow-up links in `apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php` and `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and keep collection/detail navigation on `SystemOperationRunLinks` without admin fallbacks +- [X] T016 [US2] Audit canonical system entry points in `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php`, `apps/platform/app/Filament/System/Pages/Ops/Runs.php`, and `apps/platform/app/Support/System/SystemOperationRunLinks.php` and apply only minimal cleanup needed to keep verified helper-backed system navigation admin-plane free +- [X] T017 [US2] Run the US2 system-plane verification flow documented in `specs/232-operation-run-link-contract/quickstart.md` + +**Checkpoint**: User Story 2 is independently functional and system-plane run navigation remains helper-owned and plane-correct. + +--- + +## Phase 5: User Story 3 - Prevent New Raw Operation-Link Bypasses (Priority: P2) + +**Goal**: A bounded repository guard blocks new raw operation-route assembly in platform-owned UI and shared navigation code while preserving explicitly justified infrastructure exceptions. + +**Independent Test**: Introduce a representative raw `route('admin.operations.view', ...)` or direct system operations URL inside the declared app-side boundary, confirm the guard fails with actionable output, then replace it with the canonical helper or an explicitly justified exception and confirm the guard passes. + +### Tests for User Story 3 + +- [X] T018 [P] [US3] Add bounded app-side scan coverage, exception handling, and actionable failure assertions in `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php` +- [X] T019 [P] [US3] Add guard-adjacent regression coverage for accepted delegates and canonical helper outputs in `apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` and `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php` + +### Implementation for User Story 3 + +- [X] T020 [US3] Finalize the guard include paths, forbidden patterns, and allowlisted exception entries for `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, and `apps/platform/app/Http/Controllers/ClearTenantContextController.php` in `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php` +- [X] T021 [US3] Retain the finalized allowlisted exceptions in `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, and `apps/platform/app/Http/Controllers/ClearTenantContextController.php` +- [X] T022 [US3] Run the US3 guardrail verification flow documented in `specs/232-operation-run-link-contract/quickstart.md` + +**Checkpoint**: User Story 3 is independently functional and future platform-owned raw bypasses are blocked by a bounded, reviewable guard. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finalize the contract artifacts, formatting, and focused validation workflow for the full feature. + +- [X] T023 [P] Refresh `specs/232-operation-run-link-contract/contracts/operation-run-link-contract.logical.openapi.yaml` and `specs/232-operation-run-link-contract/quickstart.md` with the final guard boundary, exception inventory, and focused validation steps +- [X] T024 Run formatting on touched app and test files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- [X] T025 Run the focused Pest suite from `specs/232-operation-run-link-contract/quickstart.md` against `apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php`, `apps/platform/tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php`, `apps/platform/tests/Feature/078/RelatedLinksOnDetailTest.php`, `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`, `apps/platform/tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php`, `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php` +- [X] T026 Record the finalized producer inventory, allowlisted exception set, guard boundary, and `document-in-feature` test-governance disposition in `specs/232-operation-run-link-contract/plan.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies; can start immediately. +- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user story work. +- **User Story 1 (Phase 3)**: Depends on Foundational completion and is the recommended first implementation increment. +- **User Story 2 (Phase 4)**: Depends on Foundational completion and can proceed once helper semantics are stable. +- **User Story 3 (Phase 5)**: Depends on User Stories 1 and 2 because the final guard boundary must reflect the settled migrated surfaces and explicit exception set. +- **Polish (Phase 6)**: Depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: Starts immediately after Foundational and delivers the primary admin-plane cleanup. +- **US2 (P1)**: Can begin after Foundational, but is easiest to validate once US1 has settled the shared helper vocabulary. +- **US3 (P2)**: Starts after US1 and US2 stabilize because the allowlist and forbidden-pattern boundary should be closed against the final adopted surface set. + +### Within Each User Story + +- Story tests should be written and fail before the corresponding implementation tasks are considered complete. +- Helper-semantics work in Phase 2 should land before any surface migration adopts the final contract. +- Shared files such as `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/System/SystemOperationRunLinks.php`, and `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php` should be edited sequentially even when surrounding tasks are otherwise parallelizable. +- Each story’s verification task should complete before moving to the next priority slice when working sequentially. + +### Parallel Opportunities + +- **Setup**: `T001`, `T002`, `T003`, and `T004` can run in parallel. +- **Foundational**: `T006` can run in parallel with the tail end of `T005` once helper inputs and delegate boundaries are settled. +- **US1 tests**: `T007`, `T008`, and `T009` can run in parallel. +- **US1 implementation**: `T010` and `T011` can run in parallel; `T012` should follow once the surrounding helper semantics are stable. +- **US2**: `T014` can run in parallel with early system normalization in `T015`; `T016` should follow once any needed directory-page normalization is clear. +- **US3 tests**: `T018` and `T019` can run in parallel. +- **Polish**: `T023` can run in parallel with `T024` once implementation is stable. + +--- + +## Parallel Example: User Story 1 + +```bash +# Run US1 coverage in parallel: +T007 apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php and apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php +T008 apps/platform/tests/Feature/Filament/InventoryCoverageRunContinuityTest.php and apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php +T009 apps/platform/tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php and apps/platform/tests/Feature/078/RelatedLinksOnDetailTest.php + +# Then split the non-overlapping admin migrations: +T010 apps/platform/app/Filament/Widgets/Tenant/RecentOperationsSummary.php and apps/platform/app/Filament/Pages/InventoryCoverage.php +T011 apps/platform/app/Filament/Resources/InventoryItemResource.php and apps/platform/app/Filament/Resources/ReviewPackResource.php +``` + +--- + +## Parallel Example: User Story 2 + +```bash +# Run US2 system assertions while normalizing residual system directory links: +T014 apps/platform/tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php and apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php +T015 apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php and apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php +``` + +--- + +## Parallel Example: User Story 3 + +```bash +# Run guard coverage in parallel with adjacent helper-output regressions: +T018 apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php +T019 apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php and apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php +``` + +--- + +## Implementation Strategy + +### First Implementation Increment (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 with `T013` before widening the slice. + +### Incremental Delivery + +1. Stabilize the helper contract and guard boundary in Setup and Foundational work. +2. Ship US1 to migrate the actual admin-plane drift surface set. +3. Add US2 to lock the system plane and preserve platform-only destination truth. +4. Add US3 to prevent future raw bypasses and make exceptions explicit. +5. Finish with contract refresh, formatting, focused tests, and close-out notes. + +### Parallel Team Strategy + +With multiple developers: + +1. One contributor can extend helper and guard tests while another prepares the admin widget/resource drill-through assertions. +2. After Phase 2, one contributor can migrate admin collection sources, another can migrate admin detail sources, and a third can normalize shared resolver or viewer fallbacks. +3. Keep `OperationRunLinks.php`, `SystemOperationRunLinks.php`, and `OperationRunLinkContractGuardTest.php` serialized because they define the shared contract boundary. + +--- + +## Notes + +- `[P]` marks tasks that can run in parallel once their prerequisites are satisfied and the touched files do not overlap. +- `[US1]`, `[US2]`, and `[US3]` map directly to the feature specification user stories. +- The first working increment is Phase 1 through Phase 3, but the approved feature-complete minimum remains Phase 1 through Phase 5 because system-plane continuity and the guardrail are part of the accepted scope. +- All tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and exact file paths. -- 2.45.2 From 6fdd45fb02fcab723545fff9edc459606d2ca7ee Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 23 Apr 2026 15:10:06 +0000 Subject: [PATCH 06/36] feat: surface stale active operation runs (#269) ## Summary - keep stale active operation runs visible in the tenant progress overlay and polling state - align tenant and canonical operation surfaces around the shared stale-active presentation contract - add Spec 233 artifacts and clean the promoted-candidate backlog entries ## Validation - browser smoke: `/admin/t/18000000-0000-4000-8000-000000000180` -> stale dashboard CTA -> `/admin/operations?tenant_id=7&activeTab=active_stale_attention&problemClass=active_stale_attention` -> `/admin/operations/15` - verified healthy vs likely-stale tenant cards, canonical stale list row, and canonical run detail consistency ## Notes - local smoke fixture seeded with one fresh and one stale running `baseline_compare` operation for browser validation - Pest suite was not re-run in this session before opening this PR Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/269 --- .github/agents/copilot-instructions.md | 4 +- .../app/Livewire/BulkOperationProgress.php | 2 +- .../platform/app/Support/OpsUx/ActiveRuns.php | 2 +- .../bulk-operation-progress.blade.php | 34 +- .../OpsUx/BulkOperationProgressDbOnlyTest.php | 8 +- .../OpsUx/ProgressWidgetFiltersTest.php | 7 +- .../OpsUx/ProgressWidgetOverflowTest.php | 16 +- docs/product/spec-candidates.md | 103 +----- .../checklists/requirements.md | 36 ++ ...tive-state-visibility.logical.openapi.yaml | 317 ++++++++++++++++++ specs/233-stale-run-visibility/data-model.md | 147 ++++++++ specs/233-stale-run-visibility/plan.md | 237 +++++++++++++ specs/233-stale-run-visibility/quickstart.md | 79 +++++ specs/233-stale-run-visibility/research.md | 49 +++ specs/233-stale-run-visibility/spec.md | 252 ++++++++++++++ specs/233-stale-run-visibility/tasks.md | 228 +++++++++++++ 16 files changed, 1410 insertions(+), 111 deletions(-) create mode 100644 specs/233-stale-run-visibility/checklists/requirements.md create mode 100644 specs/233-stale-run-visibility/contracts/operation-run-active-state-visibility.logical.openapi.yaml create mode 100644 specs/233-stale-run-visibility/data-model.md create mode 100644 specs/233-stale-run-visibility/plan.md create mode 100644 specs/233-stale-run-visibility/quickstart.md create mode 100644 specs/233-stale-run-visibility/research.md create mode 100644 specs/233-stale-run-visibility/spec.md create mode 100644 specs/233-stale-run-visibility/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 96e9b297..b4caafc7 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -242,6 +242,8 @@ ## Active Technologies - PostgreSQL via existing `findings`, `finding_exceptions`, `tenant_reviews`, `stored_reports`, and audit-log tables; no schema changes planned (231-finding-outcome-taxonomy) - PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers (232-operation-run-link-contract) - PostgreSQL-backed existing `operation_runs`, `tenants`, and `workspaces` records plus current session-backed canonical navigation state; no new persistence (232-operation-run-link-contract) +- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks` (233-stale-run-visibility) +- Existing PostgreSQL `operation_runs` records and current session/query-backed monitoring navigation state; no new persistence (233-stale-run-visibility) - PHP 8.4.15 (feat/005-bulk-operations) @@ -276,9 +278,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 233-stale-run-visibility: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks` - 232-operation-run-link-contract: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers - 231-finding-outcome-taxonomy: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger` -- 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`) ### Pre-production compatibility check diff --git a/apps/platform/app/Livewire/BulkOperationProgress.php b/apps/platform/app/Livewire/BulkOperationProgress.php index d9fd257b..1793aba9 100644 --- a/apps/platform/app/Livewire/BulkOperationProgress.php +++ b/apps/platform/app/Livewire/BulkOperationProgress.php @@ -86,7 +86,7 @@ public function refreshRuns(): void $query = OperationRun::query() ->where('tenant_id', $tenantId) - ->healthyActive() + ->active() ->orderByDesc('created_at'); $activeCount = (clone $query)->count(); diff --git a/apps/platform/app/Support/OpsUx/ActiveRuns.php b/apps/platform/app/Support/OpsUx/ActiveRuns.php index e77b1c97..218324a0 100644 --- a/apps/platform/app/Support/OpsUx/ActiveRuns.php +++ b/apps/platform/app/Support/OpsUx/ActiveRuns.php @@ -22,7 +22,7 @@ public static function existForTenantId(?int $tenantId): bool return OperationRun::query() ->where('tenant_id', $tenantId) - ->healthyActive() + ->active() ->exists(); } diff --git a/apps/platform/resources/views/livewire/bulk-operation-progress.blade.php b/apps/platform/resources/views/livewire/bulk-operation-progress.blade.php index 7f2bf54d..07c895c3 100644 --- a/apps/platform/resources/views/livewire/bulk-operation-progress.blade.php +++ b/apps/platform/resources/views/livewire/bulk-operation-progress.blade.php @@ -1,6 +1,8 @@ -@php($runs = $runs ?? collect()) -@php($overflowCount = (int) ($overflowCount ?? 0)) -@php($tenant = $tenant ?? null) +@php + $runs = $runs ?? collect(); + $overflowCount = (int) ($overflowCount ?? 0); + $tenant = $tenant ?? null; +@endphp {{-- Cleanup is delegated to the shared poller helper, which uses teardownObserver and new MutationObserver. --}} @@ -16,6 +18,17 @@ @if($runs->isNotEmpty())
@foreach ($runs->take(5) as $run) + @php + $statusSpec = \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::OperationRunStatus, + [ + 'status' => (string) $run->status, + 'freshness_state' => $run->freshnessState()->value, + ], + ); + $lifecycleAttention = \App\Support\OpsUx\OperationUxPresenter::lifecycleAttentionSummary($run); + $guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run); + @endphp
@@ -30,6 +43,21 @@ Running • {{ ($run->started_at ?? $run->created_at)?->diffForHumans(null, true, true) }} @endif

+
+ + {{ $statusSpec->label }} + + @if ($lifecycleAttention) + + {{ $lifecycleAttention }} + + @endif +
+ @if ($guidance) +

+ {{ $guidance }} +

+ @endif
@if ($tenant) diff --git a/apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php b/apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php index 41fe7470..cfcb9c4f 100644 --- a/apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php +++ b/apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php @@ -76,7 +76,7 @@ ->assertDontSee('Inventory sync'); })->group('ops-ux'); -it('does not show likely stale runs in the progress overlay and stops polling when only stale runs remain', function () { +it('shows likely stale runs in the progress overlay and keeps polling when only stale runs remain', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); $this->actingAs($user); Filament::setTenant($tenant, true); @@ -94,8 +94,10 @@ Livewire::actingAs($user) ->test(BulkOperationProgress::class) ->call('refreshRuns') - ->assertSet('hasActiveRuns', false) - ->assertDontSee('Inventory sync'); + ->assertSet('hasActiveRuns', true) + ->assertSee('Inventory sync') + ->assertSee('Likely stale') + ->assertSee('This operation is past its lifecycle window.'); })->group('ops-ux'); it('registers Alpine cleanup for the Ops UX poller to avoid stale listeners across re-renders', function () { diff --git a/apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php b/apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php index 4e6b24d4..d1ec64bb 100644 --- a/apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php +++ b/apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php @@ -46,7 +46,7 @@ expect($runs->pluck('user_id')->all())->toContain($otherUser->id); })->group('ops-ux'); -it('suppresses stale backup set update runs from the progress widget', function (string $operationType): void { +it('keeps stale backup set update runs visible in the progress widget', function (string $operationType): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $this->actingAs($user); Filament::setTenant($tenant, true); @@ -67,8 +67,9 @@ ->call('refreshRuns'); expect($component->get('runs'))->toBeInstanceOf(Collection::class) - ->and($component->get('runs'))->toHaveCount(0) - ->and($component->get('hasActiveRuns'))->toBeFalse(); + ->and($component->get('runs'))->toHaveCount(1) + ->and($component->get('runs')->first()->freshnessState()->value)->toBe('likely_stale') + ->and($component->get('hasActiveRuns'))->toBeTrue(); })->with([ 'backup set update' => 'backup_set.update', ])->group('ops-ux'); diff --git a/apps/platform/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php b/apps/platform/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php index c0ff31dc..5d2a9c52 100644 --- a/apps/platform/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php +++ b/apps/platform/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php @@ -10,10 +10,22 @@ $this->actingAs($user); Filament::setTenant($tenant, true); - OperationRun::factory()->count(7)->create([ + OperationRun::factory()->count(4)->create([ 'tenant_id' => $tenant->id, + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'inventory_sync', 'status' => 'queued', 'outcome' => 'pending', + 'created_at' => now(), + ]); + + OperationRun::factory()->count(3)->create([ + 'tenant_id' => $tenant->id, + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'inventory_sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'created_at' => now()->subHour(), ]); $component = Livewire::actingAs($user) @@ -22,4 +34,6 @@ expect($component->get('runs'))->toHaveCount(6); expect($component->get('overflowCount'))->toBe(2); + expect($component->get('runs')->map(fn (OperationRun $run): string => $run->freshnessState()->value)->unique()->values()->all()) + ->toEqualCanonicalizing(['fresh_active', 'likely_stale']); })->group('ops-ux'); diff --git a/docs/product/spec-candidates.md b/docs/product/spec-candidates.md index 5d1b000f..9481cd5e 100644 --- a/docs/product/spec-candidates.md +++ b/docs/product/spec-candidates.md @@ -5,7 +5,7 @@ # Spec Candidates > > **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-23 (promoted `Operation Run Active-State Visibility & Stale Escalation` to Spec 233, cleaned already-promoted entries for Specs 225, 231, and 232 out of `Qualified`, and kept the remaining contract-enforcement and workflow candidates aligned with the current spec stream) --- @@ -47,7 +47,11 @@ ## Promoted to Spec - Findings Operator Inbox v1 → Spec 221 (`findings-operator-inbox`) - Findings Intake & Team Queue v1 -> Spec 222 (`findings-intake-team-queue`) - Findings Notifications & Escalation v1 → Spec 224 (`findings-notifications-escalation`) +- Assignment Hygiene & Stale Work Detection → Spec 225 (`assignment-hygiene`) - Findings Notification Presentation Convergence → Spec 230 (`findings-notification-convergence`) +- Finding Outcome Taxonomy & Verification Semantics → Spec 231 (`finding-outcome-taxonomy`) +- Operation Run Link Contract Enforcement → Spec 232 (`operation-run-link-contract`) +- Operation Run Active-State Visibility & Stale Escalation → Spec 233 (`stale-run-visibility`) - Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`) - Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`) - Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`) @@ -167,81 +171,8 @@ ### Baseline Capture Truthful Outcomes and Upstream Guardrails > > **Why these are not one spec:** Each candidate has a different implementation surface, different stakeholders, and different shippability boundary. The taxonomy is a cross-cutting decision document. Reason code translation touches reason-code artifacts and notification builders. Spec 158 defines the richer artifact truth engine. The Operator Explanation Layer defines the shared interpretation semantics and explanation patterns. Governance operator outcome compression is a UI-information-architecture adoption slice across governance artifact surfaces. Humanized diagnostic summaries are an adoption slice for governance run-detail pages. Gate unification touches provider dispatch and notification plumbing across ~20 services. Merging them would create an unshippable monolith. Keeping them sequenced preserves independent delivery while still converging on one operator language. -### Operation Run Active-State Visibility & Stale Escalation -- **Type**: hardening -- **Source**: product/operator visibility analysis 2026-03-24; operation-run lifecycle and stale-state communication review -- **Vehicle**: new standalone candidate -- **Problem**: TenantPilot already has the core lifecycle foundations for `OperationRun` records: canonical run modelling, workspace-level run viewing, per-type lifecycle policies, freshness and stale detection, overdue-run reconciliation, terminal notifications, and tenant-local active-run hints. The gap is no longer primarily lifecycle logic. The gap is that the same lifecycle truth is not communicated with enough consistency and urgency across the operator surfaces that matter. A run can be past its expected lifecycle or likely stuck while still looking like normal active work on tenant-local cards or dashboard attention surfaces. Operators then have to drill into the canonical run viewer to learn that the run is no longer healthy, which weakens monitoring trust and makes hanging work look deceptively normal. -- **Why it matters**: This is an observability and operator-trust problem in a core platform layer, not visual polish. If `queued` or `running` remains visually neutral after lifecycle expectations have been exceeded, operators receive false reassurance, support burden rises, queue or worker issues are discovered later, and the product trains users that active-state surfaces are not trustworthy without manual drill-down. As TenantPilot pushes more governance, review, drift, and evidence workflows through `OperationRun`, stale active work must never read as healthy progress anywhere in the product. -- **Proposed direction**: - - Reuse the existing lifecycle, freshness, and reconciliation truth to define one **cross-surface active-state presentation contract** that distinguishes at least: `active / normal`, `active / past expected lifecycle`, `stale / likely stuck`, and `terminal / no longer active` - - Upgrade **tenant-local active-run and progress cards** so stale or past-lifecycle runs are visibly and linguistically different from healthy active work instead of reading as neutral `Queued • 1d` or `Running • 45m` - - Upgrade **tenant dashboard and attention surfaces** so they distinguish between healthy activity, activity that needs attention, and activity that is likely stale or hanging - - Upgrade the **workspace operations list / monitoring views** so problematic active runs become scanable at row level instead of being discoverable only through subtle secondary text or by opening each run - - Preserve the **workspace-level canonical run viewer** as the authoritative diagnostic surface, while ensuring compact and summary surfaces do not contradict it - - Apply a **same meaning, different density** rule: tenant cards, dashboard signals, list rows, and run detail may vary in information density, but not in lifecycle meaning or operator implication -- **Core product principles**: - - Execution lifecycle, freshness, and operator attention are related but not identical dimensions - - Compact surfaces may compress information, but must not downplay stale or hanging work - - The workspace-level run viewer remains canonical; this candidate improves visibility, not source-of-truth ownership - - Stale or past-lifecycle work must not look like healthy progress anywhere -- **Candidate requirements**: - - **R1 Cross-surface lifecycle visibility**: all relevant active-run surfaces can distinguish at least normal active, past-lifecycle active, stale/likely stuck, and terminal states - - **R2 Tenant active-run escalation**: tenant-local active-run and progress cards visibly and linguistically escalate stale or past-lifecycle work - - **R3 Dashboard attention separation**: dashboard and attention surfaces distinguish healthy activity from concerning active work - - **R4 Operations-list scanability**: the workspace operations list makes problematic active runs quickly identifiable without requiring row-by-row interpretation or drill-in - - **R5 Canonical viewer preservation**: the workspace-level run viewer remains the detailed and authoritative truth surface - - **R6 No hidden contradiction**: a run that is clearly stale or lifecycle-problematic on the detail page must not appear as ordinary active work on tenant or monitoring surfaces - - **R7 Existing lifecycle logic reuse**: the candidate reuses current freshness, lifecycle, and reconciliation semantics instead of introducing parallel UI-only heuristics - - **R8 No new backend lifecycle semantics unless necessary**: new status values or model-level lifecycle semantics are out unless the current semantics cannot carry the presentation contract cleanly -- **Scope boundaries**: - - **In scope**: tenant-local active-run cards, tenant dashboard activity and attention surfaces, workspace operations list and monitoring surfaces, shared lifecycle presentation contract for active-state visibility, copy and visual semantics needed to distinguish healthy active work from stale active work - - **Out of scope**: retry, cancel, force-fail, or reconcile-now operator actions; queue or worker architecture changes; new scheduler or timeout engines; new notification channels; a full operations-hub redesign; cross-workspace fleet monitoring; introducing new `OperationRun` status values unless existing semantics are proven insufficient -- **Acceptance points**: - - An active run outside its lifecycle expectation is visibly distinct from healthy active work on tenant-local progress cards - - Tenant dashboard and attention surfaces clearly represent the difference between healthy activity and active work that needs attention - - The workspace operations list makes stale or problematic active runs quickly scanable - - No surface shows a run as stale/problematic while another still presents it as normal active work - - The canonical workspace-level run viewer remains the most detailed lifecycle and diagnosis surface - - Existing lifecycle and freshness logic is reused rather than duplicated into local UI-only state rules - - No retry, cancel, or force-fail intervention actions are introduced by this candidate - - Fresh active runs do not regress into false escalation - - Tenant and workspace scoping remain correct; no cross-tenant leakage appears in cards or monitoring views - - Regression coverage includes fresh and stale active runs across tenant and workspace surfaces -- **Suggested test matrix**: - - queued run within expected lifecycle - - queued run well past expected lifecycle - - running run within expected lifecycle - - running run well past expected lifecycle - - run becomes terminal while an operator navigates between tenant and run-detail surfaces - - stale state on detail surface remains semantically stale on tenant and monitoring surfaces - - fresh active runs do not escalate falsely - - tenant-scoped surfaces never show another tenant's runs - - operations list clearly surfaces problematic active runs for fast scan -- **Dependencies**: existing operation-run lifecycle policy and stale detection foundations, canonical run viewer work, tenant-local active-run surfaces, operations monitoring list surfaces -- **Related specs / candidates**: Spec 114 (system console / operations monitoring foundations), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization and lifecycle continuity), Spec 161 (operator-explanation-layer), Spec 216 (Provider-Backed Action Preflight and Dispatch Gate Unification) -- **Strategic sequencing**: Best treated before any broader operator intervention-actions spec. First make stale or hanging work visibly truthful across all existing surfaces; only then add retry / force-fail / reconcile-now UX on top of a coherent active-state language. -- **Priority**: high - > Architecture contract-enforcement cluster: these candidates come from the targeted repository drift audit on 2026-04-22. Most are intentionally narrower than naming, presentation, or IA cleanup work. The shared contracts already exist; the gap is that they are not yet treated as mandatory on every platform-owned path. The operation-type candidate is the deliberate exception because the audit found two active competing semantic contracts, not just a missing guardrail. -### Operation Run Link Contract Enforcement -- **Type**: hardening / contract enforcement -- **Source**: targeted repository architecture/pattern-drift audit 2026-04-22; canonical operation-link drift review -- **Problem**: TenantPilot already has a real canonical navigation contract in `OperationRunLinks` and `SystemOperationRunLinks`, but platform-owned UI and shared layers can still build `OperationRun` links through raw `route('admin.operations...')` calls. The same navigation class is therefore emitted through two parallel paths, including in shared navigation layers that otherwise already know the canonical link contract. -- **Why it matters**: This is not a missing-helper problem. It is a shared-contract bypass on a cross-cutting operator path. If it keeps spreading, tenant/workspace context, filter continuity, deep-link stability, and future operations IA changes all become more expensive because each surface can choose raw routes instead of the contract. -- **Proposed direction**: - - inventory platform-owned `OperationRun` collection/detail link producers and classify legitimate infrastructure exceptions - - move platform-owned UI and shared navigation layers to `OperationRunLinks` or `SystemOperationRunLinks` - - make collection/detail/context semantics part of the helper contract rather than repeated local route assembly - - add a lightweight guardrail that catches new raw `route('admin.operations...')` calls outside an explicit allowlist -- **Explicit non-goals**: Not a new operations IA, not a redesign of the operations pages, not a broad routing refactor for the whole repo, and not a change to `OperationRun` page content. -- **Boundary with Operation Run Active-State Visibility & Stale Escalation**: Active-state visibility owns lifecycle communication on compact and detail surfaces. This candidate owns canonical link generation and context continuity. -- **Boundary with Operator Presentation & Lifecycle Action Hardening**: Presentation hardening owns labels, badges, and action-visibility conventions. This candidate owns deep-link and collection-link contract enforcement. -- **Dependencies**: `OperationRunLinks`, `SystemOperationRunLinks`, canonical operations pages, and any repository signal or review guardrail infrastructure introduced by Spec 201. -- **Strategic sequencing**: First of this cluster. The leverage is high because the shared contract already exists and the surface area is concrete. -- **Priority**: high - ### Canonical Operation Type Source of Truth - **Type**: hardening / source-of-truth decision - **Source**: strict read-only legacy / compatibility audit 2026-04-22; operation-type drift review @@ -432,30 +363,6 @@ ### Tenant Operational Readiness & Status Truth Hierarchy > 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. -### Assignment Hygiene & Stale Work Detection -- **Type**: workflow hardening / operations hygiene -- **Source**: findings execution layer candidate pack 2026-04-17; assignment lifecycle hygiene gap analysis -- **Problem**: Assignments can silently rot when memberships change, assignees lose access, or findings remain stuck in `in_progress` indefinitely. -- **Why it matters**: Stale and orphaned assignments erode trust in queues and create hidden backlog. Hygiene reporting is a prerequisite for later auto-reassign logic. -- **Proposed direction**: Detect orphaned assignments, invalid or inactive assignees, and stale `in_progress` work; provide an admin/operator hygiene report; define what counts as stale versus active; stop short of automatic redistribution in v1. -- **Explicit non-goals**: Full reassignment workflows, automatic load distribution, and absence management. -- **Dependencies**: Tenant membership / RBAC model, scheduler or job layer, ownership semantics, open status logic. -- **Roadmap fit**: Findings Workflow v2 hardening. -- **Strategic sequencing**: Shortly after ownership semantics, ideally alongside or immediately after notifications. -- **Priority**: high - -### Finding Outcome Taxonomy & Verification Semantics -- **Type**: workflow semantics / reporting hardening -- **Source**: findings execution layer candidate pack 2026-04-17; status/outcome reporting gap analysis -- **Problem**: Resolve and close reasoning is too free-form, and the product does not cleanly separate operator-resolved from system-verified or confirmed-cleared states. -- **Why it matters**: Reporting, audit, reopening logic, and governance review packs need structured outcomes rather than ad hoc prose. Otherwise outcome meaning drifts between operators and surfaces. -- **Proposed direction**: Define structured reason codes for resolve, close, and reopen transitions; distinguish resolved, verified or confirmed cleared, closed, false positive, duplicate, and no-longer-applicable semantics; make reporting and filter UI consume the taxonomy instead of relying on free text. -- **Explicit non-goals**: Comments, full narrative case notes, and complex multi-reason models. -- **Dependencies**: Finding status transitions, audit payloads, reporting and filter UI. -- **Roadmap fit**: Findings Workflow v2 hardening and downstream review/reporting quality. -- **Strategic sequencing**: After the first operator work surfaces unless reporting pressure pulls it earlier. -- **Priority**: high - ### Finding Comments & Decision Log v1 - **Type**: collaboration / audit depth - **Source**: findings execution layer candidate pack 2026-04-17; operator handoff and context gap analysis diff --git a/specs/233-stale-run-visibility/checklists/requirements.md b/specs/233-stale-run-visibility/checklists/requirements.md new file mode 100644 index 00000000..017fdc61 --- /dev/null +++ b/specs/233-stale-run-visibility/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Operation Run Active-State Visibility & Stale Escalation + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-23 +**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 after initial draft creation. +- No clarification markers remain. +- Candidate promoted and removed from the open `Qualified` list in `docs/product/spec-candidates.md`. \ No newline at end of file diff --git a/specs/233-stale-run-visibility/contracts/operation-run-active-state-visibility.logical.openapi.yaml b/specs/233-stale-run-visibility/contracts/operation-run-active-state-visibility.logical.openapi.yaml new file mode 100644 index 00000000..844c2800 --- /dev/null +++ b/specs/233-stale-run-visibility/contracts/operation-run-active-state-visibility.logical.openapi.yaml @@ -0,0 +1,317 @@ +openapi: 3.1.0 +info: + title: Operation Run Active-State Visibility Logical Contract + version: 1.0.0 + description: | + Internal logical contract for Spec 233. This does not introduce a new public API. + It documents the operator-visible active-state payload semantics that existing + Filament and Livewire surfaces derive from current OperationRun lifecycle truth. +servers: + - url: https://logical.tenantpilot.internal + description: Logical contract namespace only +tags: + - name: TenantActivity + - name: WorkspaceMonitoring + - name: CanonicalRunDetail +paths: + /admin/t/{tenant}/dashboard: + get: + tags: [TenantActivity] + summary: Logical tenant activity summary contract + description: | + Represents the active-state payload that tenant dashboard activity and attention + surfaces must expose. Actual implementation is HTML/Filament, not JSON. + operationId: getTenantDashboardOperationActivitySummary + x-logical-contract: true + parameters: + - $ref: '#/components/parameters/TenantId' + responses: + '200': + description: Active-state summary for tenant-visible runs + content: + application/json: + schema: + $ref: '#/components/schemas/TenantActivityOperationsSummary' + '404': + description: Tenant not visible to the current operator + /admin/t/{tenant}/operations/progress: + get: + tags: [TenantActivity] + summary: Logical tenant active-progress overlay contract + description: | + Represents the visible-active payload used by the tenant-local progress overlay. + Stale-active runs remain active for visibility and polling purposes, and + their compact elevation is rendered through the shared badge and Ops UX presenter path. + operationId: getTenantOperationProgressOverlay + x-logical-contract: true + parameters: + - $ref: '#/components/parameters/TenantId' + responses: + '200': + description: Active progress payload for tenant-scoped runs + content: + application/json: + schema: + $ref: '#/components/schemas/TenantOperationProgressOverlay' + '403': + description: Operator is a member but lacks capability to view operation runs + '404': + description: Tenant not visible to the current operator + /admin/operations: + get: + tags: [WorkspaceMonitoring] + summary: Logical canonical operations list contract + description: | + Represents the row-level semantics required by the canonical operations list. + Existing tenant prefilter continuity may be preserved, but active-state meaning + must match the unfiltered list. + operationId: listWorkspaceOperationsWithActiveStateMeaning + x-logical-contract: true + parameters: + - $ref: '#/components/parameters/TenantFilter' + - $ref: '#/components/parameters/ActiveTab' + - $ref: '#/components/parameters/ProblemClass' + responses: + '200': + description: Workspace operations list row contract + content: + application/json: + schema: + $ref: '#/components/schemas/OperationRunCollectionContract' + '404': + description: Workspace scope not visible to the current operator + /admin/operations/{run}: + get: + tags: [CanonicalRunDetail] + summary: Logical canonical run-detail contract + description: | + Represents the top-level summary semantics for a canonical operation-run detail page. + The page remains diagnostic-first while preserving the same active-state meaning seen + on compact tenant and workspace surfaces. + operationId: getCanonicalOperationRunDetailActiveStateSummary + x-logical-contract: true + parameters: + - $ref: '#/components/parameters/RunId' + responses: + '200': + description: Canonical operation-run detail summary contract + content: + application/json: + schema: + $ref: '#/components/schemas/OperationRunDetailContract' + '403': + description: Operator is a member of scope but lacks required capability + '404': + description: Run not visible to the current operator or tenant scope +components: + parameters: + TenantId: + name: tenant + in: path + required: true + schema: + type: integer + minimum: 1 + RunId: + name: run + in: path + required: true + schema: + type: integer + minimum: 1 + TenantFilter: + name: tenant + in: query + required: false + schema: + type: integer + minimum: 1 + description: Optional tenant prefilter preserved from tenant-context navigation + ActiveTab: + name: activeTab + in: query + required: false + schema: + type: string + description: Existing canonical monitoring tab selection + ProblemClass: + name: problemClass + in: query + required: false + schema: + type: string + enum: + - none + - active_stale_attention + - terminal_follow_up + schemas: + OperationRunActiveStateProjection: + type: object + additionalProperties: false + required: + - freshness_state + - problem_class + - surface_category + - is_currently_active + - is_reconciled + - show_in_active_progress + - keep_active_polling + properties: + freshness_state: + type: string + enum: + - fresh_active + - likely_stale + - reconciled_failed + - terminal_normal + - unknown + problem_class: + type: string + enum: + - none + - active_stale_attention + - terminal_follow_up + surface_category: + type: string + enum: + - healthy_active + - past_expected_lifecycle + - likely_stale + - no_longer_active + - unknown + description: | + Derived operator-facing category. Compact surfaces may use + `past_expected_lifecycle` where canonical detail uses `likely_stale` + over the same stale truth. + compact_label: + type: string + nullable: true + diagnostic_label: + type: string + nullable: true + lifecycle_label: + type: string + nullable: true + guidance: + type: string + nullable: true + stale_lineage_note: + type: string + nullable: true + is_currently_active: + type: boolean + is_reconciled: + type: boolean + show_in_active_progress: + type: boolean + keep_active_polling: + type: boolean + OperationRunSurfaceItem: + type: object + additionalProperties: false + required: + - run_id + - operation_label + - active_state + properties: + run_id: + type: integer + minimum: 1 + tenant_id: + type: integer + minimum: 1 + nullable: true + tenant_label: + type: string + nullable: true + operation_label: + type: string + status_label: + type: string + nullable: true + outcome_label: + type: string + nullable: true + active_state: + $ref: '#/components/schemas/OperationRunActiveStateProjection' + detail_url: + type: string + nullable: true + TenantActivityOperationsSummary: + type: object + additionalProperties: false + required: + - tenant_id + - items + properties: + tenant_id: + type: integer + minimum: 1 + items: + type: array + items: + $ref: '#/components/schemas/OperationRunSurfaceItem' + TenantOperationProgressOverlay: + type: object + additionalProperties: false + required: + - tenant_id + - has_visible_active_runs + - poll_interval + - items + properties: + tenant_id: + type: integer + minimum: 1 + has_visible_active_runs: + type: boolean + poll_interval: + type: string + nullable: true + enum: + - 10s + items: + type: array + items: + $ref: '#/components/schemas/OperationRunSurfaceItem' + OperationRunCollectionContract: + type: object + additionalProperties: false + required: + - items + properties: + tenant_filter: + type: integer + minimum: 1 + nullable: true + active_tab: + type: string + nullable: true + problem_class: + type: string + nullable: true + items: + type: array + items: + $ref: '#/components/schemas/OperationRunSurfaceItem' + OperationRunDetailContract: + type: object + additionalProperties: false + required: + - run_id + - operation_label + - active_state + properties: + run_id: + type: integer + minimum: 1 + operation_label: + type: string + active_state: + $ref: '#/components/schemas/OperationRunActiveStateProjection' + top_summary: + type: string + nullable: true + diagnostics_visible_after_summary: + type: boolean + const: true diff --git a/specs/233-stale-run-visibility/data-model.md b/specs/233-stale-run-visibility/data-model.md new file mode 100644 index 00000000..3b30dcd7 --- /dev/null +++ b/specs/233-stale-run-visibility/data-model.md @@ -0,0 +1,147 @@ +# Data Model: Operation Run Active-State Visibility & Stale Escalation + +## Overview + +This feature introduces no new persisted entity, table, or stored projection. It formalizes one derived active-state presentation contract over existing `OperationRun` lifecycle truth so tenant and workspace monitoring surfaces present the same meaning. + +## Source Entity: OperationRun + +- **Purpose**: Canonical lifecycle and outcome record for long-running admin-plane work. +- **Existing fields used by this feature**: + - `id` + - `workspace_id` + - `tenant_id` + - `type` + - `status` + - `outcome` + - `created_at` + - `started_at` + - `completed_at` + - `context` + - `failure_summary` +- **Existing relationships used by this feature**: + - `tenant` + - `user` where available for initiator context +- **Existing invariants**: + - Lifecycle status and outcome remain service-owned. + - Reconciliation metadata stays inside `context.reconciliation`. + - No new persisted status or outcome values are introduced for visibility purposes. + +## Derived Truth: OperationRunFreshnessState + +- **Type**: Existing enum `App\Support\Operations\OperationRunFreshnessState` +- **Values**: + - `fresh_active` + - `likely_stale` + - `reconciled_failed` + - `terminal_normal` + - `unknown` +- **Inputs**: + - `status` + - `created_at` + - `started_at` + - `context.reconciliation` + - existing `OperationLifecyclePolicy` +- **Behavioral rule**: + - This remains the only stale/late truth input for surface rendering. + - No widget, page, or Livewire component may introduce its own threshold logic. + +## Derived Truth: OperationRun Problem Class + +- **Type**: Existing derived string on `OperationRun` +- **Values**: + - `none` + - `active_stale_attention` + - `terminal_follow_up` +- **Purpose**: + - Separates active stale attention from terminal follow-up while keeping both distinct from calm/no-action runs. +- **Relationship to freshness**: + - `likely_stale` freshness yields `active_stale_attention`. + - `reconciled_failed` freshness yields `terminal_follow_up`. + - Completed blocked/partial/failed runs may also yield `terminal_follow_up` without stale lineage. + +## Derived View Model: Active-State Presentation Contract + +- **Type**: Derived, request-scoped presentation payload. Prefer reuse of `OperationUxPresenter::decisionZoneTruth()` and existing badge/presenter outputs before adding any new helper. +- **Required fields across covered surfaces**: + - `freshness_state` + - `problem_class` + - `is_currently_active` + - `is_reconciled` + - `compact_label` + - `diagnostic_label` + - `guidance` + - `stale_lineage_note` + - `show_in_active_progress` + - `keep_active_polling` +- **Presentation categories**: + - `healthy_active` + - `past_expected_lifecycle` + - `likely_stale` + - `no_longer_active` + - `unknown` fallback +- **Category mapping rules**: + - `fresh_active` + active run -> `healthy_active` + - `likely_stale` on compact summary surfaces -> `past_expected_lifecycle` + - `likely_stale` on canonical or stronger diagnostic surfaces -> `likely_stale` + - `terminal_normal` or `reconciled_failed` -> `no_longer_active` + - `unknown` -> fallback copy without false stale escalation +- **Important constraint**: + - `past_expected_lifecycle` and `likely_stale` are density variants over the same stale truth, not separate persisted states. + +## Derived Surface Policy: Tenant Active Progress Visibility + +- **Current consumers**: + - `App\Livewire\BulkOperationProgress` + - `App\Support\OpsUx\ActiveRuns` +- **Former issue**: + - Both used `healthyActive()` and therefore suppressed stale-active runs from the tenant progress overlay and polling decision. +- **Implemented rule**: + - Fresh and stale active runs remain visible as active work. + - Terminal runs disappear on the next refresh cycle. + - Polling continues while any visible active work remains, including stale-active runs. + - Overlay rendering uses the existing status badge and `OperationUxPresenter` guidance path so stale-active elevation stays derived from shared freshness truth. + +## Covered Surface Consumers + +| Consumer | Current Truth Inputs | Required Change | +|---|---|---| +| `BulkOperationProgress` | Active run query, `healthyActive()`, `ActiveRuns` | Include stale-active work in visibility and polling semantics while keeping terminal runs excluded | +| `RecentOperationsSummary` | Raw recent runs for tenant | Ensure active-state emphasis and copy stay aligned with canonical freshness meaning | +| `Dashboard\RecentOperations` | Badge rendering + `OperationUxPresenter` | Preserve and tighten existing freshness-aware row semantics | +| `Dashboard\NeedsAttention` / `DashboardKpis` | Problem-class counts + links | Keep stale-active counts and linked monitoring semantics aligned | +| `WorkspaceOverviewBuilder` / `WorkspaceRecentOperations` | Badge rendering + `OperationUxPresenter` | Preserve workspace summary consistency and diagnostic separation | +| `OperationRunResource` | Status/outcome badges + lifecycle summaries | Keep canonical list/detail authoritative and consistent with compact surfaces | +| `TenantlessOperationRunViewer` | Canonical detail page around resource truth | Preserve diagnostic-first explanation of stale versus terminal meaning | + +## State Transitions Relevant To This Feature + +1. `queued` or `running` within lifecycle threshold + - Freshness: `fresh_active` + - Presentation: `healthy_active` + - Visible on active-only compact surfaces: yes + +2. `queued` or `running` beyond lifecycle threshold + - Freshness: `likely_stale` + - Presentation: `past_expected_lifecycle` on compact surfaces, `likely_stale` on diagnostic surfaces + - Visible on active-only compact surfaces: yes + +3. `completed` without reconciliation + - Freshness: `terminal_normal` + - Presentation: `no_longer_active` + - Visible on active-only compact surfaces: no + +4. `completed` with reconciliation metadata + - Freshness: `reconciled_failed` + - Presentation: `no_longer_active` with stale-lineage diagnostics + - Visible on active-only compact surfaces: no + +## Validation Rules And Invariants + +- No new `OperationRun.status` or `OperationRun.outcome` values may be added. +- No new persisted `operation_runs` summary or mirror table may be added. +- All stale/late meaning must derive from existing freshness truth. +- Tenant-scoped surfaces must only reflect runs already visible to the current tenant-entitled operator. +- Workspace summaries must stay limited to entitled tenant slices. +- Healthy queued/running work must not inherit stale emphasis. +- Terminal runs must stop appearing in active-only surfaces on the next refresh cycle. diff --git a/specs/233-stale-run-visibility/plan.md b/specs/233-stale-run-visibility/plan.md new file mode 100644 index 00000000..2d891ce7 --- /dev/null +++ b/specs/233-stale-run-visibility/plan.md @@ -0,0 +1,237 @@ +# Implementation Plan: Operation Run Active-State Visibility & Stale Escalation + +**Branch**: `233-stale-run-visibility` | **Date**: 2026-04-23 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/233-stale-run-visibility/spec.md` + +## Summary + +Complete one shared active-state presentation contract on top of the already-existing `OperationRun` lifecycle freshness truth by converging tenant dashboard activity signals, tenant-local active-run progress cards, workspace recent-operations summaries, the canonical operations list, and canonical run detail on the same fresh-versus-past-expected-versus-likely-stale semantics without introducing new persisted run state, new status values, or page-local heuristics. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 +**Primary Dependencies**: Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks` +**Storage**: Existing PostgreSQL `operation_runs` records and current session/query-backed monitoring navigation state; no new persistence +**Testing**: Focused Pest feature tests over tenant dashboard widgets, tenant active-run progress surfaces, workspace overview operations summaries, canonical operations list/detail pages, and stale-reconciliation semantics +**Validation Lanes**: `fast-feedback`, `confidence` +**Target Platform**: Laravel admin web application in Sail containers with workspace routes under `/admin` and tenant routes under `/admin/t/{tenant}` +**Project Type**: Monorepo with one Laravel runtime in `apps/platform` and spec artifacts at repository root +**Performance Goals**: Preserve existing query shape and request-local presenter work; no additional remote calls, no background-process changes, and no new persisted summary projections +**Constraints**: No new `OperationRun.status` or `OperationRun.outcome` values, no retry/cancel/reconcile-now UX, no new notification channel, no page-local stale heuristics, no cross-tenant leakage, and no second presentation framework beyond the existing badge/presenter path +**Scale/Scope**: Existing tenant dashboard and tenant resource widgets, one Livewire active-progress slice, workspace overview builders/widgets, canonical operations list/detail pages, and their focused feature-test families + +## Filament v5 Implementation Contract + +- **Livewire v4.0+ compliance**: Preserved. The feature extends existing Filament v5 pages/widgets/resources and Livewire components without introducing legacy Livewire v3 patterns. +- **Provider registration location**: Unchanged. Panel providers remain registered in `bootstrap/providers.php`, not `bootstrap/app.php`. +- **Global search coverage**: `OperationRunResource` already keeps global search disabled via `$isGloballySearchable = false`, so this feature adds no new global-search exposure and does not depend on Edit/View global-search rules. +- **Destructive actions**: No destructive actions are introduced. Existing monitoring/detail actions remain read-only, and this feature must not add retry, cancel, or force-fail controls. +- **Asset strategy**: No new Filament assets are planned. Deployment expectations remain unchanged, including `cd apps/platform && php artisan filament:assets` only if a future implementation adds registered assets. +- **Testing plan**: Prove semantics with focused feature tests for tenant dashboard activity, tenant progress summaries, workspace recent operations, canonical operations list/detail consistency, and stale-versus-fresh boundary cases. No browser or heavy-governance lane is required for this slice. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: Changed surfaces across tenant dashboard activity, tenant-local active-run progress, workspace recent operations summaries, canonical monitoring list rows, and canonical run detail summary +- **Native vs custom classification summary**: Mixed shared-family change using native Filament widgets/resources/pages plus one existing Livewire progress component +- **Shared-family relevance**: Status messaging, dashboard signals/cards, monitoring list presentation, canonical drill-through, and run-detail summary semantics +- **State layers in scope**: `page`, `detail`, and one request-scoped/Livewire compact-progress slice; no new URL-state layer beyond existing monitoring continuity +- **Handling modes by drift class or surface**: Review-mandatory because meaning must stay aligned across multiple existing surfaces and one existing hidden gap (`healthyActive()`-only progress) must be closed without widening scope +- **Repository-signal treatment**: Review-mandatory; the feature changes operator-visible semantics but does not need a hard-stop repo guard +- **Special surface test profiles**: `standard-native-filament`, `monitoring-state-page`, `shared-detail-family` +- **Required tests or manual smoke**: `functional-core`, `state-contract` +- **Exception path and spread control**: None planned. All covered surfaces should consume existing freshness truth and shared presenter/badge paths rather than diverging locally. +- **Active feature PR close-out entry**: `Guardrail` + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `OperationRunFreshnessState`, `OperationLifecycleReconciler`, `OperationRun` problem-class helpers, `OperationUxPresenter`, centralized badge rendering, `BulkOperationProgress`, `RecentOperationsSummary`, `RecentOperations`, `DashboardKpis`, `NeedsAttention`, `WorkspaceOverviewBuilder`, `WorkspaceRecentOperations`, `OperationRunResource`, and `TenantlessOperationRunViewer` +- **Shared abstractions reused**: `OperationRun::freshnessState()`, `OperationRun::problemClass()`, `OperationRunFreshnessState`, `OperationUxPresenter::decisionZoneTruth()`, `OperationUxPresenter::lifecycleAttentionSummary()`, `OperationUxPresenter::surfaceGuidance()`, `ActiveRuns`, `BadgeCatalog` / `BadgeRenderer`, `OperationRunLinks`, and existing workspace/tenant authorization helpers +- **New abstraction introduced? why?**: One bounded derived active-state presentation contract is intentionally made explicit through the existing presenter and active-run helpers so compact and canonical surfaces can stay aligned without introducing a standalone semantic framework. +- **Why the existing abstraction was sufficient or insufficient**: Existing lifecycle truth is sufficient and authoritative. Existing compact-surface adoption is insufficient because some slices already honor freshness (`OperationRunResource`, `RecentOperations`, `WorkspaceOverviewBuilder`) while others still filter to `healthyActive()` or under-communicate stale active work. +- **Bounded deviation / spread control**: Same meaning, different density only. Surface-specific copy may vary by density, but all covered surfaces must consume the same freshness/problem-class truth and must not invent local stale logic. + +## Constitution Check + +*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design: still passed with one bounded derived presentation contract and no new persisted truth.* + +| Gate | Status | Plan Notes | +|------|--------|------------| +| Inventory-first / read-write separation | PASS | The feature is read-only presentation hardening over existing `operation_runs`; no restore, preview, or write-path change is introduced. | +| RBAC, workspace isolation, tenant isolation | PASS | Existing tenant-scoped widgets and canonical workspace monitoring routes remain on current entitlement checks; no new visibility surface is added. | +| Run observability / Ops-UX lifecycle | PASS | `OperationRunService`, lifecycle reconciliation, queued/running/terminal notifications, and run ownership remain unchanged; the plan only changes interpretation and visibility of existing run truth. | +| Shared pattern first | PASS | The plan explicitly reuses existing freshness, problem-class, presenter, and badge paths rather than adding a second semantic layer or local mapping family. | +| Proportionality / no premature abstraction | PASS | The narrowest credible change is one derived presentation contract over current truth plus convergence of existing surfaces. No new persistence, registry, or workflow framework is planned. | +| Badge semantics / Filament-native discipline | PASS | Status-like emphasis stays on centralized badge rendering and existing Filament widgets/resources/pages; no ad-hoc surface-local color system is introduced. | +| Decision-first / operator surfaces | PASS | The operations list remains the primary triage surface, tenant widgets stay secondary context, and canonical run detail stays diagnostic-first. | +| Test governance | PASS | Proof stays in focused feature lanes and existing surface families, with no browser-lane promotion and no heavy shared test infrastructure growth. | + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Feature` for tenant dashboard activity, tenant progress surfaces, workspace recent operations summaries, canonical operations list/detail consistency, and stale boundary semantics +- **Affected validation lanes**: `fast-feedback`, `confidence` +- **Why this lane mix is the narrowest sufficient proof**: The business truth is cross-surface semantic consistency over existing `OperationRun` freshness state. That is fully provable with focused feature tests against the touched widgets/pages and existing reconciliation truth; browser coverage would add cost without validating additional domain behavior. +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/OpsUx/ProgressWidgetFiltersTest.php tests/Feature/OpsUx/ProgressWidgetOverflowTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Filament/DashboardKpisWidgetTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php tests/Feature/Filament/WorkspaceOverviewOperationsTest.php tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php tests/Feature/Monitoring/MonitoringOperationsTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php` +- **Fixture / helper / factory / seed / context cost risks**: Moderate. Tests need representative fresh queued/running runs, likely stale runs, reconciled terminal runs, tenant membership context, workspace overview payloads, and hidden-tenant/non-member isolation boundaries, but existing factories and operation-run helpers already cover most of that setup. +- **Expensive defaults or shared helper growth introduced?**: No. Existing `OperationRun` factories and workspace/tenant test helpers should stay opt-in and sufficient. +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: Standard native-Filament relief plus the existing `monitoring-state-page` proving profile for canonical monitoring pages and summaries +- **Closing validation and reviewer handoff**: Re-run `pint`, then the focused feature command above. Reviewers should verify that stale active work is visible on every covered compact surface, healthy active work is not falsely escalated, and drill-through into canonical detail preserves the same active-state meaning. +- **Budget / baseline / trend follow-up**: none +- **Review-stop questions**: Did any surface invent its own stale threshold? Did `healthyActive()` filtering remain in a surface that should show stale-active attention? Did any test rely on status strings alone instead of freshness truth? Did any change accidentally widen visibility beyond entitled tenant/workspace scope? +- **Escalation path**: `document-in-feature` +- **Active feature PR close-out entry**: `Guardrail` +- **Why no dedicated follow-up spec is needed**: This is bounded current-release convergence of an existing truth family. A separate follow-up spec is only needed if later work tries to add intervention actions or a broader operations workbench. + +## Project Structure + +### Documentation (this feature) + +```text +specs/233-stale-run-visibility/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── operation-run-active-state-visibility.logical.openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/ +│ │ │ └── Operations/TenantlessOperationRunViewer.php +│ │ ├── Resources/ +│ │ │ └── OperationRunResource.php +│ │ └── Widgets/ +│ │ ├── Dashboard/ +│ │ │ ├── DashboardKpis.php +│ │ │ ├── NeedsAttention.php +│ │ │ └── RecentOperations.php +│ │ ├── Tenant/ +│ │ │ └── RecentOperationsSummary.php +│ │ └── Workspace/ +│ │ └── WorkspaceRecentOperations.php +│ ├── Livewire/ +│ │ └── BulkOperationProgress.php +│ ├── Models/ +│ │ └── OperationRun.php +│ ├── Services/Operations/ +│ │ └── OperationLifecycleReconciler.php +│ └── Support/ +│ ├── Badges/Domains/OperationRunStatusBadge.php +│ ├── OperationRunLinks.php +│ ├── OpsUx/ +│ │ ├── ActiveRuns.php +│ │ └── OperationUxPresenter.php +│ ├── Operations/OperationRunFreshnessState.php +│ └── Workspaces/WorkspaceOverviewBuilder.php +├── resources/views/ +│ ├── filament/widgets/ +│ │ ├── tenant/recent-operations-summary.blade.php +│ │ └── workspace/workspace-recent-operations.blade.php +│ └── livewire/ +│ ├── bulk-operation-progress.blade.php +│ └── bulk-operation-progress-wrapper.blade.php +└── tests/ + └── Feature/ + ├── Filament/ + │ ├── DashboardKpisWidgetTest.php + │ ├── NeedsAttentionWidgetTest.php + │ ├── OperationRunEnterpriseDetailPageTest.php + │ ├── RecentOperationsSummaryWidgetTest.php + │ └── WorkspaceOverviewOperationsTest.php + ├── Monitoring/ + │ ├── MonitoringOperationsTest.php + │ ├── OperationLifecycleFreshnessPresentationTest.php + │ └── OperationsDashboardDrillthroughTest.php + └── Operations/ + └── TenantlessOperationRunViewerTest.php + ├── OpsUx/ + │ ├── BulkOperationProgressDbOnlyTest.php + │ ├── NonLeakageWorkspaceOperationsTest.php + │ ├── ProgressWidgetFiltersTest.php + │ └── ProgressWidgetOverflowTest.php + └── RunAuthorizationTenantIsolationTest.php +``` + +**Structure Decision**: Single Laravel application inside `apps/platform`. Runtime work stays in existing monitoring widgets/resources/pages and one existing Livewire progress slice; planning artifacts stay under `specs/233-stale-run-visibility`. + +## Complexity Tracking + +No constitutional violation is planned. One bounded derived presentation contract is intentionally tracked because the spec introduces a small new semantic family over existing truth. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| BLOAT-001 derived category family | Compact surfaces currently disagree in practice about whether stale active work is still ordinary progress. One small derived contract keeps surface meaning aligned without changing persisted run state. | Leaving each widget to infer meaning from raw `status` or local heuristics would preserve drift and make future regressions likely. | + +## Proportionality Review + +- **Current operator problem**: Compact operator surfaces can still hide or understate that active work is already past its expected lifecycle, so operators get false reassurance until they drill into monitoring detail. +- **Existing structure is insufficient because**: Existing freshness truth and presenter helpers already exist, but they are not applied consistently across tenant progress and summary surfaces, and one slice (`BulkOperationProgress`) still intentionally filters stale active work out. +- **Narrowest correct implementation**: Reuse current freshness/problem-class truth, introduce at most one small derived active-state presentation contract, and retrofit only the existing tenant/workspace/canonical monitoring surfaces that already summarize active work. +- **Ownership cost created**: Small ongoing maintenance of one derived category family, shared copy alignment, and focused regression tests across covered surfaces. +- **Alternative intentionally rejected**: Adding new persisted `OperationRun` statuses or separate page-local stale heuristics. Both would widen lifecycle scope or create contradictory truth. +- **Release truth**: Current-release truth and operator-trust hardening. + +## Phase 0 Research Summary + +- Existing lifecycle and freshness truth already live in `OperationRunFreshnessState`, `OperationRun::problemClass()`, and `OperationLifecycleReconciler`; the feature should consume them rather than create new thresholds. +- Canonical monitoring surfaces already partially honor stale-active semantics: `OperationRunResource`, `Dashboard\RecentOperations`, and `WorkspaceOverviewBuilder` all feed badge/presenter state with `freshness_state` or lifecycle summaries. +- The clearest gap was tenant-local active-progress visibility: `BulkOperationProgress` scoped to `healthyActive()`, which hid stale active work from a high-frequency tenant surface and created exactly the cross-surface contradiction the spec describes. +- `OperationUxPresenter::surfaceGuidance()` already differentiates likely stale, reconciled failed, and ordinary queued/running work, so Phase 1 should extend adoption before inventing new presentation machinery. +- Existing focused tests already cover parts of the semantics (`OperationLifecycleFreshnessPresentationTest`, `MonitoringOperationsTest`, `RecentOperationsSummaryWidgetTest`, `WorkspaceOverviewOperationsTest`, `OperationRunEnterpriseDetailPageTest`, `TenantlessOperationRunViewerTest`), so implementation should prefer extending those families over introducing new broad suites. + +## Phase 1 Design Summary + +- `data-model.md` defines the derived active-state presentation model over existing `OperationRun`, freshness state, problem class, and covered surface consumers. +- `contracts/operation-run-active-state-visibility.logical.openapi.yaml` documents the internal logical contract for how covered surfaces derive and display active-state meaning from existing run truth. +- `quickstart.md` gives the narrow validation path for fresh-versus-stale fixtures, compact-surface rendering, canonical drill-through, and regression checks. + +## Implementation Strategy + +1. **Converge on one freshness-to-surface contract** + - Reuse `OperationUxPresenter::decisionZoneTruth()`, `lifecycleAttentionSummary()`, current badge helpers, and `ActiveRuns` as the default convergence path. + - Keep all thresholds and lifecycle windows owned by existing freshness truth. + +2. **Fix the tenant-local active-progress blind spot** + - Update `BulkOperationProgress` so stale active runs are not silently excluded from tenant-local progress visibility. + - Preserve calm presentation for healthy active work while allowing stale/late work to escalate visibly. + +3. **Align tenant dashboard and tenant-summary surfaces** + - Reconcile `DashboardKpis`, `NeedsAttention`, `RecentOperationsSummary`, and any shared tenant activity slices so they expose the same active-state meaning and drill-through expectations. + - Ensure mixed tenant activity does not over-generalize one stale run into “all activity is stale.” + +4. **Keep workspace and canonical monitoring surfaces authoritative** + - Reuse existing freshness-aware row/detail rendering in `OperationRunResource`, `TenantlessOperationRunViewer`, and `WorkspaceOverviewBuilder`, tightening copy and top-level summary semantics only where necessary. + - Preserve canonical list/detail roles and existing filter continuity from tenant context. + +5. **Regression-protect fresh versus stale boundaries** + - Extend the existing monitoring and Filament feature tests to prove fresh active, likely stale, reconciled terminal, and terminal-transition cases across covered surfaces. + - Explicitly assert that healthy queued/running runs do not inherit stale emphasis and that terminal runs disappear from active-only compact surfaces after refresh. + +## Risks and Mitigations + +- **Surface drift survives in one slice**: A compact surface may continue to rely on `status` only. Mitigation: inventory and update every covered surface in this plan, with tests tied to each family. +- **Over-escalation of healthy active work**: Copy or badge reuse could make all queued/running work feel unhealthy. Mitigation: keep the proving fixtures split between fresh and stale runs and assert negative cases explicitly. +- **Tenant progress regression**: Broadening `BulkOperationProgress` could accidentally turn a calm progress bar into a noisy problem board. Mitigation: keep one bounded active-state distinction and preserve existing density expectations. +- **New semantic layer grows too far**: It would be easy to invent a broader taxonomy. Mitigation: constrain the plan to one derived presentation contract backed entirely by existing freshness/problem-class truth. + +## Implementation Close-Out + +- **Finalized affected surfaces**: Tenant active progress overlay and polling now include all active `OperationRun` records, including stale-active runs. Tenant summary, dashboard KPI/attention, workspace overview, canonical operations list, and canonical detail surfaces already consume shared freshness, badge, and presenter paths and were validated without widening the runtime change. +- **Density-specific copy retained**: Compact surfaces use shared badge copy such as `Likely stale` plus `OperationUxPresenter::surfaceGuidance()` text about being past the lifecycle window. Canonical detail keeps the stronger `Likely stale operation` diagnostic banner. +- **Test-governance disposition**: `document-in-feature`. Coverage stayed inside existing feature-test families and the focused `fast-feedback` / `confidence` lanes; no browser lane, heavy-governance family, shared fixture widening, or new test infrastructure was introduced. + +## Post-Design Re-check + +Phase 0 and Phase 1 outputs keep the feature within existing `OperationRun` lifecycle truth, existing Filament/Livewire surfaces, and focused feature-test families. The plan remains constitution-compliant, Livewire v4 / Filament v5 compliant, and ready for `/speckit.tasks`. diff --git a/specs/233-stale-run-visibility/quickstart.md b/specs/233-stale-run-visibility/quickstart.md new file mode 100644 index 00000000..855fe8dd --- /dev/null +++ b/specs/233-stale-run-visibility/quickstart.md @@ -0,0 +1,79 @@ +# Quickstart: Operation Run Active-State Visibility & Stale Escalation + +## Preconditions + +1. Start the application stack: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d` +2. Work from branch `233-stale-run-visibility`. +3. Keep the scope bounded to existing admin-plane monitoring and progress surfaces. + +## Primary Files To Review First + +- `apps/platform/app/Models/OperationRun.php` +- `apps/platform/app/Support/Operations/OperationRunFreshnessState.php` +- `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` +- `apps/platform/app/Support/OpsUx/ActiveRuns.php` +- `apps/platform/app/Livewire/BulkOperationProgress.php` +- `apps/platform/app/Filament/Widgets/Tenant/RecentOperationsSummary.php` +- `apps/platform/app/Filament/Widgets/Dashboard/RecentOperations.php` +- `apps/platform/app/Filament/Resources/OperationRunResource.php` +- `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` +- `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` + +## Recommended Implementation Order + +1. **Lock the truth source** + - Confirm no surface-local stale thresholds exist outside `OperationRunFreshnessState` and `OperationRun::problemClass()`. + - Reuse `OperationUxPresenter::decisionZoneTruth()`, `lifecycleAttentionSummary()`, and `surfaceGuidance()` wherever possible. + +2. **Fix tenant progress visibility first** + - Update `ActiveRuns` and `BulkOperationProgress` so stale-active runs still count as active work for visibility and polling. + - Keep terminal runs disappearing on the next refresh cycle. + - Render stale-active elevation through the shared operation status badge and `OperationUxPresenter` guidance path. + +3. **Converge tenant and workspace summary surfaces** + - Align `RecentOperationsSummary`, `Dashboard\RecentOperations`, `Dashboard\NeedsAttention`, `DashboardKpis`, and `WorkspaceOverviewBuilder` on the same compact/detailed stale-active semantics. + - Do not create a new dashboard surface family. + +4. **Tighten canonical monitoring consistency last** + - Preserve `OperationRunResource` and `TenantlessOperationRunViewer` as the authoritative diagnostic surfaces. + - Adjust top-level explanation or row emphasis only where it improves consistency with the compact surfaces. + +5. **Update focused tests in the same slice** + - Flip stale-hidden assertions in the tenant progress tests. + - Extend monitoring, widget, and visibility-safety tests to prove fresh versus stale boundaries, terminal transitions, and hidden-tenant/non-member isolation. + +## Focused Test Matrix + +| Scenario | Expected Result | Likely Test Family | +|---|---|---| +| Fresh queued/running run on tenant surface | Visible as healthy active work, no stale escalation | `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php` | +| Stale queued/running run on tenant surface | Still visible as active work, but clearly elevated | `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `tests/Feature/OpsUx/ProgressWidgetFiltersTest.php` | +| Stale run on workspace summaries | Scanable as attention-worthy, not collapsed into calm recency | `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `tests/Feature/Monitoring/MonitoringOperationsTest.php` | +| Stale run on canonical list/detail | Same active-state meaning preserved after drill-through | `tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php`, `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` | +| Terminal transition after refresh | Removed from active-only overlays and no longer presented as active | `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php` | +| Hidden or out-of-scope runs during summary rendering | Remain invisible and do not alter visible active-state summaries | `tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`, `tests/Feature/RunAuthorizationTenantIsolationTest.php` | + +## Minimum Validation Commands + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/OpsUx/ProgressWidgetFiltersTest.php tests/Feature/OpsUx/ProgressWidgetOverflowTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Filament/DashboardKpisWidgetTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php tests/Feature/Filament/WorkspaceOverviewOperationsTest.php tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php tests/Feature/Monitoring/MonitoringOperationsTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +## Manual Smoke Checklist + +1. Seed one fresh running run and one stale queued/running run for the same tenant. +2. Open the tenant dashboard and confirm the stale run is visible and clearly elevated without making the fresh run look unhealthy. +3. Confirm the tenant progress surface keeps polling while only stale-active work remains. +4. Open `/admin/operations` and verify the same run is distinguishable at row level. +5. Drill into `/admin/operations/{run}` and confirm the top summary preserves the same active-state meaning before raw diagnostics. + +## Out Of Scope Guardrails + +- Do not add retry, cancel, reconcile-now, or worker-control actions. +- Do not add new notifications or queued/running DB notifications. +- Do not add new persisted run summary data or new `OperationRun` status values. +- Do not widen the work into a new operations workbench or cross-workspace fleet view. diff --git a/specs/233-stale-run-visibility/research.md b/specs/233-stale-run-visibility/research.md new file mode 100644 index 00000000..1f042b7f --- /dev/null +++ b/specs/233-stale-run-visibility/research.md @@ -0,0 +1,49 @@ +# Research: Operation Run Active-State Visibility & Stale Escalation + +## Decision 1: Keep lifecycle freshness truth in the existing run model and reconciler + +- **Decision**: Use `OperationRunFreshnessState`, `OperationRun::freshnessState()`, `OperationRun::problemClass()`, and `OperationLifecycleReconciler` as the only lifecycle-truth inputs for this feature. +- **Rationale**: The application already computes `fresh_active`, `likely_stale`, `reconciled_failed`, `terminal_normal`, and `unknown` from the run record plus `OperationLifecyclePolicy`. Canonical monitoring surfaces already rely on that truth, so adding a second stale heuristic would immediately recreate the drift this spec is trying to remove. +- **Alternatives considered**: + - Add new `OperationRun.status` values such as `stale` or `late`: rejected because the distinction is presentation and triage-oriented, not a new persisted lifecycle state. + - Add page-local thresholds per widget: rejected because it would create conflicting meaning across tenant, workspace, and canonical monitoring surfaces. + +## Decision 2: Reuse the existing Ops UX presenter path before introducing a new helper + +- **Decision**: Prefer `OperationUxPresenter::decisionZoneTruth()`, `lifecycleAttentionSummary()`, `surfaceGuidance()`, and centralized badge rendering as the presentation backbone. +- **Rationale**: The code already exposes a derived decision-zone payload and shared stale/reconciled copy. `OperationRunStatusBadge` already renders `Likely stale` when queued/running work carries `freshness_state=likely_stale`, and `OperationUxPresenter` already provides compact and diagnostic explanations off the same truth. +- **Alternatives considered**: + - New dedicated presenter family for active-state visibility: rejected unless the existing presenter path proves insufficient during implementation. + - Widget-local copy branches: rejected because they would increase semantic spread and regression risk. + +## Decision 3: Treat stale-active runs as still active for tenant progress visibility + +- **Decision**: Change tenant-local active-progress visibility to include freshness-elevated active runs rather than suppressing them via `healthyActive()`. +- **Rationale**: `BulkOperationProgress` and `ActiveRuns::existForTenantId()` previously used `healthyActive()`, which caused stale queued/running work to disappear from the tenant progress overlay and stopped polling when only stale runs remained. That was the clearest concrete contradiction with the canonical monitoring surfaces. +- **Alternatives considered**: + - Keep stale runs hidden in the progress overlay and rely on dashboard/list only: rejected because the spec explicitly covers tenant-local active-run cards and progress summaries. + - Add a separate stale-only overlay: rejected because it would create a second active-work surface family instead of fixing the existing one. + +## Decision 4: Preserve current surface roles and drill-through flow + +- **Decision**: Keep the current route and surface model: tenant dashboard and tenant progress remain secondary context, `/admin/operations` remains the primary triage list, and `/admin/operations/{run}` remains diagnostic-first. +- **Rationale**: Existing links already converge through `OperationRunLinks`, and current pages/widgets match the constitution's decision-first model. The gap is the honesty of compact active-state messaging, not missing routes. +- **Alternatives considered**: + - New operations hub or new tenant-local detail page: rejected as unnecessary workflow expansion. + - New notification channel for stale active work: rejected because the spec explicitly excludes new notification behavior. + +## Decision 5: Extend existing focused tests and invert stale-hidden assumptions where necessary + +- **Decision**: Update existing monitoring, Filament, and Ops UX tests rather than creating a new broad suite. +- **Rationale**: The repository already has focused coverage for lifecycle presentation and tenant progress behavior. In particular, `BulkOperationProgressDbOnlyTest` and `ProgressWidgetFiltersTest` currently codify the stale-hidden behavior that this feature must deliberately replace. +- **Alternatives considered**: + - Add a brand-new browser suite: rejected because feature tests already cover the underlying business truth and UI copy. + - Leave old progress-widget tests untouched and add parallel tests: rejected because the old assertions would preserve the wrong contract. + +## Decision 6: Keep “past expected lifecycle” and “likely stale” as density-specific labels over the same stale truth + +- **Decision**: Model compact “past expected lifecycle” phrasing and stronger “likely stale” diagnostic phrasing as different density outputs over the same `likely_stale` freshness truth rather than as separate persisted states. +- **Rationale**: The spec allows same meaning, different density. The current code already points in that direction: `OperationUxPresenter::surfaceGuidance()` says the run is “past its lifecycle window,” while `OperationRunStatusBadge` can label the same run `Likely stale`. +- **Alternatives considered**: + - Create two separate freshness states for “late” and “likely stale”: rejected because existing lifecycle truth has only one stale boundary and no additional behavioral consequence. + - Collapse all stale-active copy to a single label everywhere: rejected because compact surfaces and canonical detail need different density without changing meaning. diff --git a/specs/233-stale-run-visibility/spec.md b/specs/233-stale-run-visibility/spec.md new file mode 100644 index 00000000..8718e04d --- /dev/null +++ b/specs/233-stale-run-visibility/spec.md @@ -0,0 +1,252 @@ +# Feature Specification: Operation Run Active-State Visibility & Stale Escalation + +**Feature Branch**: `233-stale-run-visibility` +**Created**: 2026-04-23 +**Status**: Draft +**Input**: User description: "Operation Run Active-State Visibility & Stale Escalation" + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: `OperationRun` lifecycle truth already exists, but compact operator surfaces still under-communicate when active work is past expectation or likely stuck. +- **Today's failure**: A run can be visibly stale on the canonical monitoring detail yet still read like ordinary `Queued` or `Running` work on tenant cards, dashboard attention surfaces, or list rows. Operators then receive false reassurance until they drill into monitoring. +- **User-visible improvement**: Active runs that are healthy, late, or likely stuck become visibly distinct across tenant and workspace surfaces, so operators can see unhealthy work in one scan without losing the canonical run detail as the diagnostic source of truth. +- **Smallest enterprise-capable version**: Add one bounded active-state presentation contract derived from existing lifecycle, freshness, and reconciliation truth, then apply it consistently to tenant dashboard activity surfaces, tenant-local active-run cards, the workspace operations list, and the canonical run detail summary. +- **Explicit non-goals**: No new `OperationRun` status values, no retry or cancel actions, no queue or worker redesign, no new notification channel, no full operations hub redesign, no cross-workspace fleet monitoring, and no parallel UI-only stale heuristic that bypasses existing lifecycle truth. +- **Permanent complexity imported**: One bounded derived active-state category family over existing run truth, one shared cross-surface presentation contract, focused operator copy updates on existing monitoring surfaces, and regression coverage for fresh versus stale semantics across tenant and workspace entry points. +- **Why now**: This is an active near-term operator-trust hardening item in the roadmap, and it becomes more urgent as more governance, evidence, and review workflows depend on `OperationRun`. Spec 232 now hardens link continuity into canonical monitoring, so the next high-leverage gap is what those linked surfaces actually communicate about unhealthy active work. +- **Why not local**: Fixing one widget or one row badge would still leave contradictory lifecycle meaning across cards, list rows, dashboard attention, and run detail. The problem is cross-surface truth drift, not one local rendering bug. +- **Approval class**: Core Enterprise +- **Red flags triggered**: Cross-cutting interaction-class scope plus one new derived presentation category family. Defense: the feature derives entirely from existing lifecycle and freshness truth, avoids persistence or backend-state expansion, and stays bounded to existing admin-plane surfaces. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace, tenant, canonical-view +- **Primary Routes**: + - `/admin/t/{tenant}` for tenant dashboard activity and attention surfaces + - Existing tenant-local active-run cards linked from tenant-scoped admin surfaces that already summarize active work + - `/admin/operations` as the canonical workspace monitoring list + - `/admin/operations/{run}` as the canonical run detail surface +- **Data Ownership**: `operation_runs` remain the only source of lifecycle and freshness truth. Tenant-local cards, dashboard signals, and workspace monitoring rows remain derived read models over existing run records, lifecycle policy, and stale-detection truth. No new persisted visibility flag, summary mirror, or auxiliary active-run table is introduced. +- **RBAC**: Admin-plane workspace membership remains required for `/admin/operations` and run detail visibility. Tenant-scoped surfaces remain constrained by current tenant entitlement. Non-members and out-of-scope tenant requests remain deny-as-not-found. This feature does not introduce new capability strings, new roles, or new mutation permissions. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: When operators enter `/admin/operations` from a tenant-scoped surface, the canonical monitoring list may preserve the active tenant as a default prefilter while retaining the same workspace-level route and clearing behavior used by existing canonical monitoring links. +- **Explicit entitlement checks preventing cross-tenant leakage**: Tenant-local cards and dashboard signals may summarize only runs already visible to the current operator within that tenant. The canonical operations list and run detail continue to re-check workspace membership and tenant entitlement before rendering. No stale or past-lifecycle signal may reveal the existence of hidden runs or tenants. + +## 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 +- **Interaction class(es)**: status messaging, dashboard signals/cards, monitoring list presentation, canonical run detail summary +- **Systems touched**: tenant dashboard activity surfaces, tenant-local active-run cards, workspace monitoring list rows, canonical monitoring detail, and existing canonical drill-through links into `/admin/operations` +- **Existing pattern(s) to extend**: current `OperationRun` lifecycle policies, freshness/stale detection, canonical monitoring pages, shared badge semantics, and existing tenant-to-monitoring drill-through patterns +- **Shared contract / presenter / builder / renderer to reuse**: `OperationRunService` remains the sole owner of lifecycle transitions; `App\Support\OperationCatalog` remains the canonical operation label source; existing central badge rendering remains the status-like rendering path; existing canonical operations links remain the navigation path into `/admin/operations` +- **Why the existing shared path is sufficient or insufficient**: Existing lifecycle, freshness, and reconciliation truth are sufficient and must remain authoritative. Existing compact-surface presentation is insufficient because it compresses unhealthy active work too aggressively and can contradict the canonical run detail. +- **Allowed deviation and why**: Same meaning, different density is allowed. Tenant cards, dashboard signals, list rows, and run detail may vary in information density, but they may not disagree about whether active work is healthy, late, or likely stuck. +- **Consistency impact**: Active-state language, status emphasis, badge meaning, next-step cues, and drill-through expectations must stay aligned across tenant dashboards, tenant cards, monitoring rows, and canonical detail. +- **Review focus**: Reviewers must verify that no compact surface presents a run as ordinary active work when the canonical monitoring detail presents it as past expectation or likely stuck, and that no new page-local stale heuristic bypasses the shared lifecycle truth. + +## 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 dashboard activity and attention surfaces | yes | Native Filament dashboard widgets and shared summary primitives | Same tenant-home signal family as existing attention and activity summaries | summary, attention cues, drill-through links | no | Existing surfaces only; no new dashboard page | +| Tenant-local active-run cards and progress summaries | yes | Native Filament widgets/cards and shared run primitives | Same tenant-scoped activity family as existing recent-run and progress hints | compact active-state presentation, drill-through links | no | Existing cards only; no new card family | +| Workspace operations list / monitoring rows | yes | Native Filament table and shared run presentation primitives | Same canonical monitoring family as existing `/admin/operations` list | row-level active-state emphasis, filter continuity, list scanability | no | Existing registry surface only | +| Canonical operation run detail summary | yes | Native Filament detail surface and shared run summary primitives | Same canonical monitoring detail family | top-level active-state explanation, diagnostics separation | no | Detail remains diagnostic-first, not a new page | + +## 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 dashboard activity and attention surfaces | Secondary Context Surface | An operator lands on the tenant dashboard and decides whether current tenant activity needs immediate follow-up | Whether active work is healthy, needs attention, or likely stuck, plus one drill-through into monitoring | Full run history, raw run context, and extended diagnostics on the canonical monitoring surfaces | Secondary because the dashboard signals attention but should not replace the monitoring register | Follows tenant-home to monitoring workflow instead of creating a second workbench | Removes the need to open monitoring just to learn that active work is already stale or late | +| Tenant-local active-run cards and progress summaries | Secondary Context Surface | An operator inspects one tenant-scoped active-work summary and decides whether to open monitoring now | Current run identity, active-state category, and whether the work is merely active or needs attention | Full lifecycle history, stale-cause detail, and related diagnostics on run detail | Secondary because the card summarizes active work inside a broader tenant workflow | Follows tenant-scoped operational follow-up without duplicating monitoring detail | Prevents misleading neutral `Queued` or `Running` summaries from hiding unhealthy work | +| Workspace operations list / monitoring rows | Primary Decision Surface | A workspace operator scans the active operations register and decides which run needs inspection first | Whether active runs are normal, past expectation, or likely stuck, together with run identity and scope context | Full run diagnostics, raw context, and related artifacts on run detail | Primary because this is the canonical list where operators prioritize run follow-up | Follows monitoring and triage workflow instead of forcing row-by-row drill-in | Makes unhealthy active runs scanable without opening every row | +| Canonical operation run detail summary | Tertiary Evidence / Diagnostics Surface | After choosing one run, the operator confirms what kind of active-state problem exists and what it means | Clear top-level explanation of active-state category, lifecycle expectation status, and why diagnostics matter | Raw payloads, stack traces, reconciliation context, and deep technical detail | Tertiary because the operator first decides to inspect the run elsewhere; this page then provides the authoritative diagnosis | Preserves the existing monitoring-detail workflow | Reduces back-and-forth between list rows and diagnostics to understand whether the run is merely active or likely stuck | + +## 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 dashboard activity and attention surfaces | Monitoring / Queue / Workbench | Read-only Registry / Report Surface | Open the tenant-scoped monitoring slice or the highlighted run | Explicit drill-through CTA or linked run summary | forbidden | Dashboard CTAs remain limited to monitoring follow-up | none | `/admin/t/{tenant}` | `/admin/operations/{run}` | Tenant context, activity state, attention weighting | Operations / Operation | Whether tenant-visible active work is healthy, late, or likely stuck | Embedded summary drill-in only | +| Tenant-local active-run cards and progress summaries | Monitoring / Queue / Workbench | Read-only Registry / Report Surface | Open the run detail or canonical monitoring list | Explicit linked run summary | forbidden | Card CTA stays secondary to the active-state summary | none | `/admin/t/{tenant}` | `/admin/operations/{run}` | Tenant context, active work scope, active-state emphasis | Active operations / Operation | Whether the highlighted active work is ordinary progress or already needs attention | Embedded compact summary; no new surface family | +| Workspace operations list / monitoring rows | List / Table / Bulk | Read-only Registry / Report Surface | Open the run most likely to need follow-up | Full-row open to canonical run detail | required | Existing filters and list controls stay in table chrome, not row noise | none | `/admin/operations` | `/admin/operations/{run}` | Workspace scope, optional tenant prefilter, active-state emphasis | Operations / Operation | Which active runs are healthy versus problematic without opening every row | none | +| Canonical operation run detail summary | Record / Detail / Edit | Detail-first Operational Surface | Inspect one run's active-state explanation and diagnostics | Canonical run detail page | n/a | Related links and secondary diagnostics stay below the top-level summary | Existing dangerous follow-up actions remain wherever already governed; none are added here | `/admin/operations` | `/admin/operations/{run}` | Workspace scope, tenant context when applicable, active-state explanation | Operation run | Why the run is healthy, late, or likely stuck before raw diagnostics appear | canonical evidence detail | + +## 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 dashboard activity and attention surfaces | Tenant operator | Decide whether tenant-visible activity already needs monitoring follow-up | Summary drill-in | Is anything actively happening on this tenant that is already late or likely stuck? | Active-state category, tenant-visible run identity, and one drill-through path | Full run diagnostics and raw lifecycle context remain in monitoring | execution lifecycle, freshness, attention state | none | Open monitoring or run detail | none | +| Tenant-local active-run cards and progress summaries | Tenant operator | Decide whether the highlighted active run is still ordinary progress | Compact run summary | Is this active work still healthy, or does it already need attention? | Run identity, active-state category, elapsed-state emphasis | Deep diagnostics, raw context, and reconciliation detail remain on run detail | execution lifecycle, freshness, active-state interpretation | none | Open run detail | none | +| Workspace operations list / monitoring rows | Workspace operator | Prioritize which active run deserves inspection first | Read-only monitoring registry | Which active runs are normal, and which are already late or likely stuck? | Run identity, scope, high-level lifecycle, and active-state emphasis | Raw payloads and detailed run history remain on run detail | lifecycle, freshness, problem emphasis | none | Open run detail, apply filters | none | +| Canonical operation run detail summary | Workspace operator | Confirm the meaning of the active-state issue before deeper diagnosis | Diagnostic detail surface | Why does this active run read as late or likely stuck, and what should I inspect next? | Top-level active-state explanation, scope, and summary guidance | Raw payloads, stack traces, and extended technical details | lifecycle, freshness, diagnostic readiness | none | Open related context or diagnostics | 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 active-state presentation contract expressed through existing presenter and active-run helpers rather than a standalone framework +- **New enum/state/reason family?**: yes — one derived operator-facing category family for `active / normal`, `active / past expectation`, `stale / likely stuck`, and `terminal / no longer active`, composed from existing freshness and problem-class truth +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Operators cannot trust compact active-work surfaces because they can understate or hide the difference between healthy progress and likely stuck work. +- **Existing structure is insufficient because**: Existing lifecycle truth lives in monitoring detail and backend services, but compact surfaces can still render active work as neutral `Queued` or `Running` states without exposing that the lifecycle expectation has already been exceeded. +- **Narrowest correct implementation**: Keep all backend lifecycle truth as-is, add one derived presentation contract, and retrofit only the existing tenant/dashboard/monitoring surfaces that already summarize active work. +- **Ownership cost**: Ongoing maintenance for one small presentation category family, cross-surface wording alignment, and regression coverage for fresh versus stale semantics. +- **Alternative intentionally rejected**: Introducing new `OperationRun` statuses or page-local stale heuristics was rejected because both would either widen persistence and lifecycle scope or create contradictory truth outside the existing lifecycle policy. +- **Release truth**: Current-release truth. This feature makes existing active-run observability honest now rather than preparing a future intervention framework. + +### 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 proof burden is operator-visible active-state semantics across existing admin surfaces. Focused feature coverage is sufficient to prove fresh versus stale differentiation, tenant/workspace visibility boundaries, cross-surface consistency, and no false escalation without needing browser or heavy-governance lanes. +- **New or expanded test families**: Extend `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `tests/Feature/OpsUx/ProgressWidgetFiltersTest.php`, `tests/Feature/OpsUx/ProgressWidgetOverflowTest.php`, `tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php`, `tests/Feature/Monitoring/MonitoringOperationsTest.php`, `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `tests/Feature/RunAuthorizationTenantIsolationTest.php`, and `tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`. +- **Fixture / helper cost impact**: Moderate. Tests need representative `OperationRun` fixtures for healthy queued/running work, past-expectation work, likely stuck work, terminal transitions during navigation, mixed tenant visibility, and hidden-tenant/non-member isolation boundaries. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: monitoring-state-page +- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, plus explicit proof that healthy active runs do not escalate falsely and that stale semantics remain consistent after drill-through from tenant surfaces into canonical monitoring. +- **Reviewer handoff**: Reviewers must confirm that the same active-state meaning appears on tenant cards, dashboard attention, operations rows, and canonical run detail; that no new backend status values or UI-only stale heuristics are introduced; and that hidden or out-of-scope runs do not influence visible summaries. +- **Budget / baseline / trend impact**: none +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/OpsUx/ProgressWidgetFiltersTest.php tests/Feature/OpsUx/ProgressWidgetOverflowTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Filament/DashboardKpisWidgetTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php tests/Feature/Filament/WorkspaceOverviewOperationsTest.php tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php tests/Feature/Monitoring/MonitoringOperationsTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - See unhealthy tenant activity without opening monitoring first (Priority: P1) + +As a tenant operator, I want tenant-scoped activity surfaces to distinguish healthy active work from late or likely stuck work, so that I can notice unhealthy runs before manually opening the monitoring register. + +**Why this priority**: Tenant cards and dashboard attention surfaces are the highest-frequency compact summaries for active work. If they remain misleading, the rest of the monitoring model is harder to trust. + +**Independent Test**: Can be fully tested by seeding healthy and stale tenant-visible active runs, rendering the tenant dashboard and active-run cards, and verifying that only late or likely stuck work escalates while healthy work stays calm. + +**Acceptance Scenarios**: + +1. **Given** a tenant-visible run is queued or running within its expected lifecycle, **When** the tenant dashboard or active-run card renders, **Then** the run appears as healthy active work and does not escalate as stale or likely stuck. +2. **Given** a tenant-visible run is queued or running well past its expected lifecycle, **When** the same compact surfaces render, **Then** the run is visibly and linguistically distinct from healthy active work. +3. **Given** a run becomes terminal, **When** the tenant-scoped compact surfaces refresh, **Then** the run no longer appears as active work. + +--- + +### User Story 2 - Scan problematic active runs in the canonical operations list (Priority: P1) + +As a workspace operator, I want the canonical operations list to make problematic active runs obvious at row level, so that I can prioritize follow-up without opening each run. + +**Why this priority**: The canonical operations list is the primary monitoring surface for run triage. If unhealthy active runs are not scanable there, operators lose the central prioritization surface. + +**Independent Test**: Can be fully tested by seeding a mix of fresh and stale active runs across visible tenants and verifying that the operations list highlights problematic rows without falsely escalating healthy rows. + +**Acceptance Scenarios**: + +1. **Given** the operations list contains a mix of healthy active runs and likely stuck runs, **When** the workspace operator opens `/admin/operations`, **Then** the problematic active runs are immediately distinguishable at row level. +2. **Given** the operator enters `/admin/operations` from a tenant-scoped surface, **When** the canonical list opens with tenant context preserved, **Then** the same active-state semantics remain visible within that filtered monitoring slice. +3. **Given** an active run is fresh, **When** the operations list renders, **Then** it does not inherit the same escalation treatment as a late or likely stuck run. + +--- + +### User Story 3 - Keep compact surfaces aligned with canonical run detail (Priority: P2) + +As a workspace operator, I want canonical run detail to confirm the same active-state meaning that I saw on tenant and list surfaces, so that I can trust compact summaries without losing diagnostic depth. + +**Why this priority**: The canonical detail page is the authoritative diagnostic surface. It must confirm, not contradict, the meaning shown elsewhere. + +**Independent Test**: Can be fully tested by navigating from tenant-scoped or list surfaces into run detail for both healthy and stale runs and verifying that the same active-state meaning holds after drill-through. + +**Acceptance Scenarios**: + +1. **Given** a run reads as likely stuck on a tenant surface or list row, **When** the operator opens canonical run detail, **Then** the detail summary confirms that the run is past expectation or likely stuck before exposing raw diagnostics. +2. **Given** a run changes from active to terminal while the operator navigates between surfaces, **When** the compact surface and run detail refresh, **Then** neither surface continues to present the run as active. +3. **Given** a scheduled or initiator-null run becomes late, **When** the operator inspects it through monitoring, **Then** the active-state semantics remain truthful without implying a new notification channel or mutation behavior. + +### Edge Cases + +- A run may move from healthy to late or likely stuck between two page loads; the presentation contract must tolerate state changes without requiring a manual semantic reset. +- An operator may open the canonical run detail from a tenant-filtered monitoring slice; the run detail must remain authoritative while preserving scope context where already allowed. +- Multiple active runs may exist for one tenant; compact surfaces must not imply that all tenant activity is stale because one run is problematic. +- A run may become terminal while the operator is on a tenant dashboard or monitoring list; compact surfaces and run detail must converge on non-active presentation after refresh. +- Initiator-null scheduled work may become stale without producing a terminal DB notification; monitoring semantics must remain truthful without inventing a new scheduled-run notification contract. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature adds no Microsoft Graph calls, no new write flow, and no new `OperationRun`. It changes only how existing run lifecycle and freshness truth are interpreted on admin-plane operator surfaces. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces one derived active-state presentation contract because current-release operator trust requires it now. A narrower local patch is insufficient because the same truth must remain aligned across tenant cards, dashboard signals, list rows, and canonical detail. The addition stays derived, not persisted. + +**Constitution alignment (XCUT-001):** This feature is cross-cutting across status messaging and dashboard/monitoring surfaces. It reuses the existing lifecycle, freshness, badge, and canonical monitoring paths, and it allows only one bounded deviation: same meaning, different density. + +**Constitution alignment (TEST-GOV-001):** Focused feature tests on tenant dashboard surfaces, tenant active-run cards, monitoring rows, and run detail are the narrowest sufficient proof. No browser or heavy-governance lane is required. + +**Constitution alignment (OPS-UX):** Existing Ops-UX 3-surface feedback remains unchanged. Toasts stay intent-only, progress surfaces remain the existing active-work surfaces, and terminal DB notifications stay unchanged. `OperationRun.status` and `OperationRun.outcome` transitions remain service-owned exclusively through `OperationRunService`. This feature must not introduce new `summary_counts` keys or any new scheduled-run notification semantics. + +**Constitution alignment (RBAC-UX):** The feature operates only in the admin plane (`/admin` and `/admin/t/{tenant}/...`). Tenant-scoped compact surfaces remain tenant-entitlement safe, while canonical monitoring routes continue to enforce workspace membership and tenant entitlement on tenant-owned runs. No cross-plane visibility or raw capability checks may be added. + +**Constitution alignment (BADGE-001):** Any changed status-like emphasis must continue to use centralized badge or status rendering. No page-local mapping from stale-detection inputs to color or label semantics is allowed. + +**Constitution alignment (UI-FIL-001):** Touched surfaces must use native Filament widgets, tables, summaries, and existing shared status primitives. No custom local status markup or page-local color systems may replace shared run presentation. + +**Constitution alignment (UI-NAMING-001):** Operator-facing language must use one canonical vocabulary for active-state meaning: healthy active work, past expected lifecycle, likely stuck, and no longer active. Implementation-first phrases or raw stale heuristics must not become primary labels. + +**Constitution alignment (DECIDE-001):** The operations list remains the primary decision surface for run triage. Tenant dashboard surfaces remain secondary context surfaces, and canonical run detail remains the diagnostic surface. This feature must make those roles calmer and clearer, not create a new workbench. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** No new route, no new inspect model, and no new destructive action family are introduced. The canonical inspect model remains the run detail page. Compact surfaces may add emphasis and drill-through clarity only. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from `queued` and `running` alone is insufficient because lifecycle expectation and freshness change the operator meaning of active work. The feature adds one bounded derived interpretation layer and must prove business consequences across surfaces rather than only unit-testing one presenter. + +### Functional Requirements + +- **FR-001**: The system MUST derive one active-state presentation category for visible runs using existing lifecycle, freshness, and reconciliation truth. +- **FR-002**: The derived presentation contract MUST distinguish at least `active / normal`, `active / past expected lifecycle`, `stale / likely stuck`, and `terminal / no longer active`. +- **FR-003**: The feature MUST NOT introduce new `OperationRun.status` or `OperationRun.outcome` values to express those categories. +- **FR-004**: Tenant dashboard activity and attention surfaces MUST visibly and linguistically distinguish healthy active work from active work that is late or likely stuck. +- **FR-005**: Tenant-local active-run cards and progress summaries MUST surface the same active-state meaning as the tenant dashboard attention layer for the same run. +- **FR-006**: The workspace operations list MUST make late or likely stuck active runs distinguishable at row level without requiring drill-in. +- **FR-007**: Canonical run detail MUST explain the active-state category before exposing raw diagnostics, and that explanation MUST remain consistent with the compact surfaces that linked into it. +- **FR-008**: A run that is presented as late or likely stuck on canonical run detail MUST NOT appear as ordinary healthy active work on any covered tenant or monitoring surface. +- **FR-009**: Healthy active runs MUST NOT inherit stale or likely-stuck emphasis solely because they are `queued` or `running`. +- **FR-010**: When a run becomes terminal, covered compact surfaces MUST stop presenting it as active work on the next refresh cycle. +- **FR-011**: Existing lifecycle, freshness, and reconciliation logic MUST remain the source of truth; covered surfaces MUST NOT implement separate page-local stale heuristics. +- **FR-012**: When operators enter canonical monitoring from tenant context, existing tenant-prefilter continuity MAY be preserved, but the active-state semantics MUST remain identical to the unfiltered canonical list. +- **FR-013**: Covered surfaces MUST remain tenant-safe and workspace-safe; hidden runs or hidden tenants MUST NOT influence visible active-state summaries. +- **FR-014**: Scheduled or initiator-null runs MUST use the same active-state presentation rules as user-initiated runs where lifecycle and freshness truth are comparable. +- **FR-015**: This feature MUST NOT add retry, cancel, force-fail, reconcile-now, or other intervention actions to any covered surface. +- **FR-016**: Existing Ops-UX toast, progress-surface, and terminal-notification behavior MUST remain unchanged. + +## 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 dashboard activity and attention surfaces | `/admin/t/{tenant}` | none added by this feature | Explicit dashboard CTA or linked run summary | none | none | none | n/a | n/a | No new mutation audit because the surface remains read-only | Action Surface Contract satisfied. Dashboard remains a signal surface, not a second workbench. UI-FIL-001 satisfied through native widgets and shared status primitives. | +| Tenant-local active-run cards and progress summaries | Tenant-scoped admin surfaces that already summarize active work | none added by this feature | Explicit linked run summary | none | none | none | n/a | n/a | No new mutation audit because the surface remains read-only | One primary inspect model remains the run detail. No redundant View action is added. | +| Workspace operations list | `/admin/operations` | Existing filters only; none added by this feature | Full-row open to run detail | none added by this feature | none | Existing list empty state unchanged | n/a | n/a | No new mutation audit because the surface remains read-only | Action Surface Contract satisfied. Row click stays the only primary inspect model. | +| Canonical operation run detail summary | `/admin/operations/{run}` | Existing safe/context actions unchanged | n/a | n/a | n/a | n/a | Existing detail/header actions unchanged | n/a | No new mutation audit because the feature changes summary semantics only | No exemption needed. This feature changes top-level explanation, not the action layout. | + +### Key Entities *(include if feature involves data)* + +- **Active-state presentation category**: A derived operator-facing category that explains whether visible active work is healthy, late, likely stuck, or no longer active. +- **Lifecycle expectation window**: The existing timing and policy truth that determines when active work has exceeded its normal expected lifecycle. +- **Stale active run**: An existing `OperationRun` whose lifecycle and freshness truth indicate that active work is likely stuck rather than merely still in progress. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In acceptance review, an operator can distinguish healthy tenant-visible active work from likely stuck work in one scan of the covered tenant surfaces. +- **SC-002**: 100% of covered fresh-versus-stale automated scenarios show the correct active-state category on the tenant dashboard, tenant active-run cards, and workspace operations list. +- **SC-003**: 100% of covered drill-through scenarios preserve the same active-state meaning between compact surfaces and canonical run detail. +- **SC-004**: 100% of covered healthy-active scenarios avoid false escalation when lifecycle expectation has not yet been exceeded. diff --git a/specs/233-stale-run-visibility/tasks.md b/specs/233-stale-run-visibility/tasks.md new file mode 100644 index 00000000..68a9de98 --- /dev/null +++ b/specs/233-stale-run-visibility/tasks.md @@ -0,0 +1,228 @@ +# Tasks: Operation Run Active-State Visibility & Stale Escalation + +**Input**: Design documents from `/specs/233-stale-run-visibility/` +**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/operation-run-active-state-visibility.logical.openapi.yaml`, `quickstart.md` + +**Tests**: Required. This feature changes runtime behavior across tenant progress surfaces, tenant dashboard summaries, workspace summaries, and canonical monitoring detail, so Pest coverage must be added or updated in `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php`, `apps/platform/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php`, `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php`, `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php`, `apps/platform/tests/Feature/Monitoring/MonitoringOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`, and `apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`. +**Operations**: No new `OperationRun` is introduced. Existing lifecycle truth, reconciliation, toast/progress/terminal notification behavior, and service-owned status/outcome transitions must remain unchanged. +**RBAC**: The feature stays in the admin plane (`/admin` and `/admin/t/{tenant}/...`). It must preserve current tenant-entitlement and workspace-entitlement behavior, including non-member `404`, in-scope capability denial semantics, and tenant-safe summaries with no cross-tenant leakage. +**UI / Surface Guardrails**: The changed surfaces are native Filament widgets/resources/pages plus one existing Livewire progress component. The feature keeps `monitoring-state-page` coverage for canonical monitoring surfaces, uses `standard-native-filament` relief elsewhere, and remains `review-mandatory` because multiple existing operator surfaces must converge on the same truth. +**Filament UI Action Surfaces**: `RecentOperationsSummary`, tenant dashboard widgets, `OperationRunResource`, and `TenantlessOperationRunViewer` keep their existing inspect/open model. No new header, row, bulk, retry, cancel, or destructive actions are introduced. +**Badges**: Status-like semantics must stay on `BadgeCatalog` / `BadgeRenderer` and existing shared `OperationRun` presenter paths. No page-local stale badge mapping is allowed. + +**Organization**: Tasks are grouped by user story so each slice is independently implementable and testable. Recommended delivery order is `US1 -> US2 -> US3` because tenant-surface honesty is the most urgent gap, canonical list scanability builds on the same truth, and detail-surface confirmation should close last against the final compact-surface semantics. + +## 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 (Shared Surface Scaffolding) + +**Purpose**: Prepare the focused regression surfaces that will prove fresh-versus-stale semantics before runtime files are edited. + +- [X] T001 [P] Extend stale-versus-fresh progress-overlay scaffolding in `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php`, and `apps/platform/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php` +- [X] T002 [P] Extend tenant dashboard and tenant-summary semantics scaffolding in `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php`, `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php`, and `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php` +- [X] T003 [P] Extend workspace monitoring and visibility-safety scaffolding in `apps/platform/tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/MonitoringOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php`, `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`, and `apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php` +- [X] T004 [P] Extend canonical detail and drill-through continuity scaffolding in `apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` and `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php` + +**Checkpoint**: Focused tenant, workspace, and canonical test surfaces are ready to fail on stale-hidden regressions before implementation begins. + +--- + +## Phase 2: Foundational (Blocking Truth And Shared Contract) + +**Purpose**: Stabilize the one freshness-to-surface contract before any individual surface is changed. + +**Critical**: No user story work should begin until this phase is complete. + +- [X] T005 Freeze the canonical stale/fresh truth inputs and any needed thin derived adapter boundaries in `apps/platform/app/Models/OperationRun.php`, `apps/platform/app/Support/Operations/OperationRunFreshnessState.php`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, and `apps/platform/app/Support/OpsUx/ActiveRuns.php` +- [X] T006 [P] Refresh the feature contract artifacts in `specs/233-stale-run-visibility/contracts/operation-run-active-state-visibility.logical.openapi.yaml` and `specs/233-stale-run-visibility/quickstart.md` so implementation and review language stay aligned with the finalized shared truth path + +**Checkpoint**: The feature has one agreed freshness/problem-class/presenter contract and the docs match that contract before surface-by-surface retrofits begin. + +--- + +## Phase 3: User Story 1 - See unhealthy tenant activity without opening monitoring first (Priority: P1) 🎯 MVP + +**Goal**: Tenant-scoped dashboard and progress surfaces distinguish healthy active work from late or likely stuck work without inventing a second stale heuristic. + +**Independent Test**: Seed fresh and stale tenant-visible queued/running runs, render tenant dashboard and progress surfaces, and verify that healthy work stays calm while stale-active work remains visible and elevated until the run becomes terminal. + +### Tests for User Story 1 + +- [X] T007 [P] [US1] Add fresh-versus-stale tenant progress assertions, including polling continuity while only stale-active runs remain, in `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php`, and `apps/platform/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php` +- [X] T008 [P] [US1] Add tenant summary and tenant dashboard copy/assertion coverage for calm-versus-elevated active work in `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php`, `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php`, and `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php` + +### Implementation for User Story 1 + +- [X] T009 [P] [US1] Update stale-active visibility and polling semantics in `apps/platform/app/Support/OpsUx/ActiveRuns.php`, `apps/platform/app/Livewire/BulkOperationProgress.php`, `apps/platform/resources/views/livewire/bulk-operation-progress.blade.php`, and `apps/platform/resources/views/livewire/bulk-operation-progress-wrapper.blade.php` +- [X] T010 [P] [US1] Align tenant activity summaries with the shared presenter/badge truth in `apps/platform/app/Filament/Widgets/Tenant/RecentOperationsSummary.php`, `apps/platform/resources/views/filament/widgets/tenant/recent-operations-summary.blade.php`, `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php`, and `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php` +- [X] T011 [US1] Tighten tenant-surface copy and density handling in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` and any touched shared badge mappings in `apps/platform/app/Support/Badges/Domains/OperationRunStatusBadge.php` without adding a second semantic framework +- [X] T012 [US1] Run the US1 tenant-surface verification flow from `specs/233-stale-run-visibility/quickstart.md` + +**Checkpoint**: User Story 1 is independently functional and tenant operators can see unhealthy active work before opening canonical monitoring. + +--- + +## Phase 4: User Story 2 - Scan problematic active runs in the canonical operations list (Priority: P1) + +**Goal**: Workspace monitoring rows and workspace recent-operation summaries make problematic active runs obvious at scan time without falsely escalating fresh work. + +**Independent Test**: Seed a mixed slice of fresh and stale active runs across visible tenants, open workspace summaries and `/admin/operations`, and verify that stale-active rows are immediately distinguishable while fresh active rows remain calm. + +### Tests for User Story 2 + +- [X] T013 [P] [US2] Add workspace summary, recency, and visibility-safety assertions for fresh-versus-stale active work in `apps/platform/tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/MonitoringOperationsTest.php`, `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`, and `apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php` +- [X] T014 [P] [US2] Add canonical operations-list assertions for row-level stale-active scanability and tenant-prefilter continuity in `apps/platform/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php` and `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php` + +### Implementation for User Story 2 + +- [X] T015 [P] [US2] Align workspace summary payloads and view rendering in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `apps/platform/app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php`, and `apps/platform/resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php` +- [X] T016 [P] [US2] Tighten canonical list scanability and stale-active row semantics in `apps/platform/app/Filament/Widgets/Dashboard/RecentOperations.php` and `apps/platform/app/Filament/Resources/OperationRunResource.php` +- [X] T017 [US2] Reconcile any remaining stale-active badge/copy differences across workspace and canonical list surfaces in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` and `apps/platform/app/Support/Badges/Domains/OperationRunStatusBadge.php` +- [X] T018 [US2] Run the US2 workspace-list verification flow from `specs/233-stale-run-visibility/quickstart.md` + +**Checkpoint**: User Story 2 is independently functional and workspace operators can scan the canonical monitoring list for unhealthy active work without opening every row. + +--- + +## Phase 5: User Story 3 - Keep compact surfaces aligned with canonical run detail (Priority: P2) + +**Goal**: Canonical run detail confirms the same active-state meaning that compact tenant and workspace surfaces already communicate, including terminal transitions and stale lineage. + +**Independent Test**: Navigate from compact tenant/workspace surfaces into canonical run detail for fresh, stale, and reconciled-terminal runs, then verify that the top summary preserves the same meaning before deeper diagnostics render. + +### Tests for User Story 3 + +- [X] T019 [P] [US3] Add canonical detail summary and stale-lineage assertions in `apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` and `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php` +- [X] T020 [P] [US3] Add refresh-boundary and terminal-transition consistency assertions spanning compact-to-detail flows in `apps/platform/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php` and `apps/platform/tests/Feature/Monitoring/MonitoringOperationsTest.php` + +### Implementation for User Story 3 + +- [X] T021 [P] [US3] Align canonical detail summary copy and decision-zone truth in `apps/platform/app/Filament/Resources/OperationRunResource.php` and `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` +- [X] T022 [US3] Align tenantless canonical viewer summary behavior and drill-through continuity in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and any touched related monitoring helpers under `apps/platform/app/Support/OperationRunLinks.php` +- [X] T023 [US3] Run the US3 compact-to-detail verification flow from `specs/233-stale-run-visibility/quickstart.md` + +**Checkpoint**: User Story 3 is independently functional and canonical run detail confirms, rather than contradicts, the compact active-state meaning. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finalize documentation, formatting, and focused validation for the whole feature without widening scope. + +- [X] T024 [P] Refresh `specs/233-stale-run-visibility/plan.md`, `specs/233-stale-run-visibility/research.md`, and `specs/233-stale-run-visibility/data-model.md` if implementation proves a thinner shared contract or adjusts touched file scope +- [X] T025 Run formatting on touched application and test files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- [X] T026 Run the focused Pest suite from `specs/233-stale-run-visibility/quickstart.md` against `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php`, `apps/platform/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php`, `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php`, `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php`, `apps/platform/tests/Feature/Monitoring/MonitoringOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`, and `apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php` +- [X] T027 Record the finalized affected surfaces, any retained density-specific copy decisions, and the `document-in-feature` test-governance disposition in `specs/233-stale-run-visibility/plan.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies; can start immediately. +- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user story work. +- **User Story 1 (Phase 3)**: Depends on Foundational completion and is the recommended first implementation increment. +- **User Story 2 (Phase 4)**: Depends on Foundational completion and can begin after the shared truth contract is stable. +- **User Story 3 (Phase 5)**: Depends on User Stories 1 and 2 because canonical detail should be aligned against the final compact-surface semantics. +- **Polish (Phase 6)**: Depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: Starts immediately after Foundational and delivers the highest-value tenant-surface honesty fix. +- **US2 (P1)**: Can begin after Foundational, but is easiest to complete after US1 settles the compact stale-active vocabulary. +- **US3 (P2)**: Starts after US1 and US2 stabilize because canonical detail should confirm the final compact-surface contract, not compete with an in-progress one. + +### Within Each User Story + +- Story tests should be written and fail before the corresponding implementation tasks are considered complete. +- Shared files such as `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/OpsUx/ActiveRuns.php`, and `apps/platform/app/Filament/Resources/OperationRunResource.php` should be edited sequentially even when surrounding tasks are otherwise parallelizable. +- Each story's verification task should complete before moving to the next priority slice when working sequentially. + +### Parallel Opportunities + +- **Setup**: `T001`, `T002`, `T003`, and `T004` can run in parallel. +- **Foundational**: `T006` can run in parallel with the tail end of `T005` once the shared contract is clear. +- **US1 tests**: `T007` and `T008` can run in parallel. +- **US1 implementation**: `T009` and `T010` can run in parallel; `T011` should follow once the touched surface outputs are visible. +- **US2 tests**: `T013` and `T014` can run in parallel. +- **US2 implementation**: `T015` and `T016` can run in parallel; `T017` should follow once both summary and canonical list semantics are visible. +- **US3 tests**: `T019` and `T020` can run in parallel. +- **Polish**: `T024` can run in parallel with `T025` after runtime implementation is stable. + +--- + +## Parallel Example: User Story 1 + +```bash +# Run tenant-surface test work in parallel: +T007 apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php, apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php, apps/platform/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php +T008 apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php, apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php, apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php, apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php + +# Then split the non-overlapping implementation work: +T009 apps/platform/app/Support/OpsUx/ActiveRuns.php, apps/platform/app/Livewire/BulkOperationProgress.php, apps/platform/resources/views/livewire/bulk-operation-progress.blade.php, apps/platform/resources/views/livewire/bulk-operation-progress-wrapper.blade.php +T010 apps/platform/app/Filament/Widgets/Tenant/RecentOperationsSummary.php, apps/platform/resources/views/filament/widgets/tenant/recent-operations-summary.blade.php, apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php, apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php +``` + +--- + +## Parallel Example: User Story 2 + +```bash +# Run workspace-summary, visibility-safety, and canonical-list assertions in parallel: +T013 apps/platform/tests/Feature/Filament/WorkspaceOverviewOperationsTest.php, apps/platform/tests/Feature/Monitoring/MonitoringOperationsTest.php, apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php, and apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php +T014 apps/platform/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php and apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php +``` + +--- + +## Parallel Example: User Story 3 + +```bash +# Run canonical-detail and transition-consistency assertions in parallel: +T019 apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php and apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php +T020 apps/platform/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php and apps/platform/tests/Feature/Monitoring/MonitoringOperationsTest.php +``` + +--- + +## Implementation Strategy + +### First Implementation Increment (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 with `T012` before widening the slice. + +### Incremental Delivery + +1. Stabilize the shared freshness/problem-class contract and docs. +2. Ship US1 to fix the tenant-surface blind spot and stale-hidden progress behavior. +3. Ship US2 to make workspace summaries and the canonical list scanable. +4. Ship US3 to ensure canonical detail confirms the same meaning after drill-through. +5. Finish with formatting, focused tests, and close-out notes. + +### Parallel Team Strategy + +With multiple developers: + +1. One contributor can own Ops UX progress visibility while another extends tenant dashboard/widget assertions. +2. After Phase 2, one contributor can update workspace summary builders while another adjusts canonical list/detail semantics. +3. Keep `OperationUxPresenter.php`, `ActiveRuns.php`, and `OperationRunResource.php` serialized because they anchor the shared truth and surface contract. + +--- + +## Notes + +- `[P]` marks tasks that can run in parallel once prerequisites are satisfied and touched files do not overlap. +- `[US1]`, `[US2]`, and `[US3]` map directly to the feature specification user stories. +- The first working increment is Phase 1 through Phase 3, but the feature-complete approved scope remains Phase 1 through Phase 5 because canonical list and detail alignment are part of the accepted problem statement. +- All tasks above use exact repository paths and keep the work bounded to the existing admin-plane monitoring and progress surfaces. -- 2.45.2 From 603d509b8f092328bbf2574c10395be197effe5d Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 23 Apr 2026 16:54:48 +0000 Subject: [PATCH 07/36] cleanup: retire dead transitional residue (#270) ## Summary - remove deprecated baseline profile status alias constants and keep baseline lifecycle semantics on the canonical enum path - retire the dead tenant app-status badge/default-fixture residue from the active runtime support path - add the `234-dead-transitional-residue` spec, plan, research, data-model, quickstart, checklist, and task artifacts plus focused regression assertions ## Validation - not rerun during this PR creation step Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/270 --- .github/agents/copilot-instructions.md | 4 +- .../SeedBackupHealthBrowserFixture.php | 1 - apps/platform/app/Models/BaselineProfile.php | 15 -- .../app/Support/Badges/BadgeCatalog.php | 1 - .../app/Support/Badges/BadgeDomain.php | 1 - .../Badges/Domains/TenantAppStatusBadge.php | 24 -- .../database/factories/TenantFactory.php | 1 - .../BaselineProfileArchiveActionTest.php | 4 + .../BaselineProfileWorkspaceOwnershipTest.php | 10 + .../BaselineProfileListFiltersTest.php | 2 + .../BaselineProfileScopeV2PersistenceTest.php | 9 +- ...antLifecycleStatusDomainSeparationTest.php | 5 + .../TenantTruthCleanupSpec179Test.php | 16 ++ .../tests/Unit/Badges/BadgeCatalogTest.php | 11 + .../tests/Unit/Badges/TenantBadgesTest.php | 19 +- .../checklists/requirements.md | 35 +++ .../data-model.md | 86 ++++++ specs/234-dead-transitional-residue/plan.md | 245 ++++++++++++++++++ .../quickstart.md | 80 ++++++ .../234-dead-transitional-residue/research.md | 41 +++ specs/234-dead-transitional-residue/spec.md | 223 ++++++++++++++++ specs/234-dead-transitional-residue/tasks.md | 225 ++++++++++++++++ 22 files changed, 999 insertions(+), 59 deletions(-) delete mode 100644 apps/platform/app/Support/Badges/Domains/TenantAppStatusBadge.php create mode 100644 specs/234-dead-transitional-residue/checklists/requirements.md create mode 100644 specs/234-dead-transitional-residue/data-model.md create mode 100644 specs/234-dead-transitional-residue/plan.md create mode 100644 specs/234-dead-transitional-residue/quickstart.md create mode 100644 specs/234-dead-transitional-residue/research.md create mode 100644 specs/234-dead-transitional-residue/spec.md create mode 100644 specs/234-dead-transitional-residue/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index b4caafc7..0951743d 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -244,6 +244,8 @@ ## Active Technologies - PostgreSQL-backed existing `operation_runs`, `tenants`, and `workspaces` records plus current session-backed canonical navigation state; no new persistence (232-operation-run-link-contract) - PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks` (233-stale-run-visibility) - Existing PostgreSQL `operation_runs` records and current session/query-backed monitoring navigation state; no new persistence (233-stale-run-visibility) +- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests (234-dead-transitional-residue) +- Existing PostgreSQL `baseline_profiles` and `tenants` tables; no new persistence and no schema migration in this slice (234-dead-transitional-residue) - PHP 8.4.15 (feat/005-bulk-operations) @@ -278,9 +280,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 234-dead-transitional-residue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests - 233-stale-run-visibility: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks` - 232-operation-run-link-contract: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers -- 231-finding-outcome-taxonomy: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger` ### Pre-production compatibility check diff --git a/apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php b/apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php index d79c65a9..641a0d1d 100644 --- a/apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php +++ b/apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php @@ -67,7 +67,6 @@ public function handle(): int 'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'), 'tenant_id' => $tenantRouteKey, 'app_certificate_thumbprint' => null, - 'app_status' => 'ok', 'app_notes' => null, 'status' => Tenant::STATUS_ACTIVE, 'environment' => 'dev', diff --git a/apps/platform/app/Models/BaselineProfile.php b/apps/platform/app/Models/BaselineProfile.php index f4bee8ae..91a7a517 100644 --- a/apps/platform/app/Models/BaselineProfile.php +++ b/apps/platform/app/Models/BaselineProfile.php @@ -20,21 +20,6 @@ class BaselineProfile extends Model { use HasFactory; - /** - * @deprecated Use BaselineProfileStatus::Draft instead. - */ - public const string STATUS_DRAFT = 'draft'; - - /** - * @deprecated Use BaselineProfileStatus::Active instead. - */ - public const string STATUS_ACTIVE = 'active'; - - /** - * @deprecated Use BaselineProfileStatus::Archived instead. - */ - public const string STATUS_ARCHIVED = 'archived'; - /** @var list */ protected $fillable = [ 'workspace_id', diff --git a/apps/platform/app/Support/Badges/BadgeCatalog.php b/apps/platform/app/Support/Badges/BadgeCatalog.php index af407a9e..3c4daf1d 100644 --- a/apps/platform/app/Support/Badges/BadgeCatalog.php +++ b/apps/platform/app/Support/Badges/BadgeCatalog.php @@ -38,7 +38,6 @@ final class BadgeCatalog BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class, BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class, BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class, - BadgeDomain::TenantAppStatus->value => Domains\TenantAppStatusBadge::class, BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class, BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class, BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class, diff --git a/apps/platform/app/Support/Badges/BadgeDomain.php b/apps/platform/app/Support/Badges/BadgeDomain.php index efc973b9..69a0f4b1 100644 --- a/apps/platform/app/Support/Badges/BadgeDomain.php +++ b/apps/platform/app/Support/Badges/BadgeDomain.php @@ -29,7 +29,6 @@ enum BadgeDomain: string case BooleanEnabled = 'boolean_enabled'; case BooleanHasErrors = 'boolean_has_errors'; case TenantStatus = 'tenant_status'; - case TenantAppStatus = 'tenant_app_status'; case TenantRbacStatus = 'tenant_rbac_status'; case TenantPermissionStatus = 'tenant_permission_status'; case PolicySnapshotMode = 'policy_snapshot_mode'; diff --git a/apps/platform/app/Support/Badges/Domains/TenantAppStatusBadge.php b/apps/platform/app/Support/Badges/Domains/TenantAppStatusBadge.php deleted file mode 100644 index 7959b03a..00000000 --- a/apps/platform/app/Support/Badges/Domains/TenantAppStatusBadge.php +++ /dev/null @@ -1,24 +0,0 @@ - new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'), - 'configured' => new BadgeSpec('Configured', 'success', 'heroicon-m-check-circle'), - 'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'), - 'requires_consent', 'consent_required' => new BadgeSpec('Consent required', 'warning', 'heroicon-m-exclamation-triangle'), - 'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'), - default => BadgeSpec::unknown(), - }; - } -} diff --git a/apps/platform/database/factories/TenantFactory.php b/apps/platform/database/factories/TenantFactory.php index 00d55854..cb97d604 100644 --- a/apps/platform/database/factories/TenantFactory.php +++ b/apps/platform/database/factories/TenantFactory.php @@ -42,7 +42,6 @@ public function definition(): array 'app_client_id' => fake()->uuid(), 'app_client_secret' => null, // Skip encryption in tests 'app_certificate_thumbprint' => null, - 'app_status' => 'ok', 'app_notes' => null, 'status' => 'active', 'environment' => 'other', diff --git a/apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php b/apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php index 360009cc..52e5e0c0 100644 --- a/apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php +++ b/apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php @@ -43,6 +43,10 @@ it('archives baseline profiles for authorized workspace members', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); + expect(defined(BaselineProfile::class.'::STATUS_DRAFT'))->toBeFalse() + ->and(defined(BaselineProfile::class.'::STATUS_ACTIVE'))->toBeFalse() + ->and(defined(BaselineProfile::class.'::STATUS_ARCHIVED'))->toBeFalse(); + $profile = BaselineProfile::factory()->active()->create([ 'workspace_id' => (int) $tenant->workspace_id, ]); diff --git a/apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php b/apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php index 79895149..3ff86eb4 100644 --- a/apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php +++ b/apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use App\Filament\Resources\BaselineProfileResource; +use App\Models\BaselineProfile; +use App\Support\Baselines\BaselineProfileStatus; use Filament\Facades\Filament; it('keeps baseline profiles out of tenant panel registration and tenant navigation URLs', function (): void { @@ -23,6 +25,11 @@ it('keeps baseline profile urls workspace-owned even when a tenant context exists', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); + $profile = BaselineProfile::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => BaselineProfileStatus::Archived->value, + ]); + $this->actingAs($user) ->withSession([\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); @@ -32,5 +39,8 @@ expect($workspaceUrl)->not->toContain("/admin/t/{$tenant->external_id}/baseline-profiles"); $this->get($workspaceUrl)->assertOk(); + $this->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))->assertOk(); $this->get("/admin/t/{$tenant->external_id}/baseline-profiles")->assertNotFound(); + + expect($profile->fresh()->status)->toBe(BaselineProfileStatus::Archived); }); diff --git a/apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php b/apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php index c62aef29..efb439dd 100644 --- a/apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php +++ b/apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php @@ -14,6 +14,8 @@ it('filters baseline profiles by status inside the current workspace', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); + expect(defined(BaselineProfile::class.'::STATUS_ACTIVE'))->toBeFalse(); + $active = BaselineProfile::factory()->active()->create([ 'workspace_id' => (int) $tenant->workspace_id, ]); diff --git a/apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php b/apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php index bae001b9..dec53224 100644 --- a/apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php +++ b/apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php @@ -7,6 +7,7 @@ use App\Filament\Resources\BaselineProfileResource\Pages\EditBaselineProfile; use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile; use App\Models\BaselineProfile; +use App\Support\Baselines\BaselineProfileStatus; use App\Support\Governance\GovernanceSubjectTaxonomyRegistry; use App\Support\Workspaces\WorkspaceContext; use Illuminate\Validation\ValidationException; @@ -45,7 +46,7 @@ expect($profile->scope_jsonb)->toBe([ 'policy_types' => ['deviceConfiguration'], 'foundation_types' => ['assignmentFilter'], - ]); + ])->and($profile->status)->toBe(BaselineProfileStatus::Draft); expect($profile->canonicalScopeJsonb())->toBe([ 'version' => 2, @@ -83,7 +84,7 @@ 'name' => 'Legacy baseline profile', 'description' => null, 'version_label' => null, - 'status' => 'active', + 'status' => BaselineProfileStatus::Active->value, 'capture_mode' => 'opportunistic', 'scope_jsonb' => json_encode([ 'policy_types' => [], @@ -178,7 +179,7 @@ 'name' => 'Legacy lineage profile', 'description' => null, 'version_label' => null, - 'status' => 'active', + 'status' => BaselineProfileStatus::Active->value, 'capture_mode' => 'opportunistic', 'scope_jsonb' => json_encode([ 'policy_types' => ['deviceConfiguration'], @@ -224,4 +225,4 @@ ]))->toThrow(ValidationException::class, 'Filters are not supported'); expect(BaselineProfile::query()->where('name', 'Invalid filtered baseline')->exists())->toBeFalse(); -}); \ No newline at end of file +}); diff --git a/apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php b/apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php index 257ab4b8..fe17798f 100644 --- a/apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php +++ b/apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php @@ -25,6 +25,8 @@ role: 'owner', ); + expect($tenant->fresh()->app_status)->toBe('consent_required'); + $this->actingAs($user); Filament::setTenant($tenant, true); @@ -33,11 +35,14 @@ ->assertSee('Lifecycle summary') ->assertSee('This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.') ->assertDontSee('App status') + ->assertDontSee('Consent required') ->assertSee('RBAC status') ->assertSee('Failed'); }); it('keeps referenced tenant lifecycle context separate from run status in the tenantless operations viewer', function (): void { + expect(array_key_exists('app_status', Tenant::factory()->onboarding()->raw()))->toBeFalse(); + $tenant = Tenant::factory()->onboarding()->create([ 'name' => 'Viewer Separation Tenant', ]); diff --git a/apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php b/apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php index 17fb31d9..346f606c 100644 --- a/apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php +++ b/apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php @@ -38,6 +38,8 @@ 'verification_status' => ProviderVerificationStatus::Unknown->value, ]); + expect($tenant->fresh()->app_status)->toBe('ok'); + $this->actingAs($user); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); @@ -61,6 +63,17 @@ ->and($visibleColumnNames)->not->toContain('provider_connection_state'); }); +it('keeps legacy app status as opt-in test setup instead of a factory default', function (): void { + expect(array_key_exists('app_status', Tenant::factory()->raw()))->toBeFalse(); + + $tenant = Tenant::factory()->create([ + 'name' => 'Explicit Historical App Status Tenant', + 'app_status' => 'error', + ]); + + expect($tenant->fresh()->app_status)->toBe('error'); +}); + it('keeps lifecycle and rbac separate while leading the provider summary with consent and verification', function (): void { $tenant = Tenant::factory()->create([ 'status' => Tenant::STATUS_ONBOARDING, @@ -86,6 +99,8 @@ 'verification_status' => ProviderVerificationStatus::Blocked->value, ]); + expect($tenant->fresh()->app_status)->toBe('consent_required'); + $this->actingAs($user); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); @@ -97,6 +112,7 @@ ->assertSee('RBAC status') ->assertSee('Failed') ->assertDontSee('App status') + ->assertDontSee('Consent required') ->assertSee('Truth Cleanup Connection') ->assertSee('Lifecycle') ->assertSee('Disabled') diff --git a/apps/platform/tests/Unit/Badges/BadgeCatalogTest.php b/apps/platform/tests/Unit/Badges/BadgeCatalogTest.php index c365357e..09dda093 100644 --- a/apps/platform/tests/Unit/Badges/BadgeCatalogTest.php +++ b/apps/platform/tests/Unit/Badges/BadgeCatalogTest.php @@ -48,3 +48,14 @@ expect(BadgeCatalog::mapper(BadgeDomain::BooleanEnabled))->not->toBeNull() ->and($domainValues)->not->toContain('provider_connection.status', 'provider_connection.health'); }); + +it('keeps retired tenant app-status out of the central catalog while active tenant domains remain registered', function (): void { + $domainValues = collect(BadgeDomain::cases()) + ->map(fn (BadgeDomain $domain): string => $domain->value) + ->all(); + + expect($domainValues)->not->toContain('tenant_app_status') + ->and(BadgeCatalog::mapper(BadgeDomain::TenantStatus))->not->toBeNull() + ->and(BadgeCatalog::mapper(BadgeDomain::TenantRbacStatus))->not->toBeNull() + ->and(BadgeCatalog::mapper(BadgeDomain::TenantPermissionStatus))->not->toBeNull(); +}); diff --git a/apps/platform/tests/Unit/Badges/TenantBadgesTest.php b/apps/platform/tests/Unit/Badges/TenantBadgesTest.php index 4235c4a7..13436561 100644 --- a/apps/platform/tests/Unit/Badges/TenantBadgesTest.php +++ b/apps/platform/tests/Unit/Badges/TenantBadgesTest.php @@ -23,18 +23,15 @@ expect($error->color)->toBe('danger'); }); -it('maps tenant app status values to legacy diagnostic badge semantics', function (): void { - $ok = BadgeCatalog::spec(BadgeDomain::TenantAppStatus, 'ok'); - expect($ok->label)->toBe('OK'); - expect($ok->color)->toBe('success'); +it('does not expose retired app status as active tenant badge semantics', function (): void { + $domainValues = collect(BadgeDomain::cases()) + ->map(fn (BadgeDomain $domain): string => $domain->value) + ->all(); - $consentRequired = BadgeCatalog::spec(BadgeDomain::TenantAppStatus, 'consent_required'); - expect($consentRequired->label)->toBe('Consent required'); - expect($consentRequired->color)->toBe('warning'); - - $error = BadgeCatalog::spec(BadgeDomain::TenantAppStatus, 'error'); - expect($error->label)->toBe('Error'); - expect($error->color)->toBe('danger'); + expect($domainValues)->not->toContain('tenant_app_status') + ->and(BadgeCatalog::mapper(BadgeDomain::TenantStatus))->not->toBeNull() + ->and(BadgeCatalog::mapper(BadgeDomain::TenantRbacStatus))->not->toBeNull() + ->and(BadgeCatalog::mapper(BadgeDomain::TenantPermissionStatus))->not->toBeNull(); }); it('maps tenant RBAC status values to canonical badge semantics', function (): void { diff --git a/specs/234-dead-transitional-residue/checklists/requirements.md b/specs/234-dead-transitional-residue/checklists/requirements.md new file mode 100644 index 00000000..811d0205 --- /dev/null +++ b/specs/234-dead-transitional-residue/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Dead Transitional Residue Cleanup + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-23 +**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 current cleanup sequencing in `docs/product/spec-candidates.md` and the active near-term roadmap focus in `docs/product/roadmap.md` on 2026-04-23. +- Scope stays intentionally narrow: remove dead residue first, defer schema removal and adjacent cleanup strands to follow-up specs. \ No newline at end of file diff --git a/specs/234-dead-transitional-residue/data-model.md b/specs/234-dead-transitional-residue/data-model.md new file mode 100644 index 00000000..4296320d --- /dev/null +++ b/specs/234-dead-transitional-residue/data-model.md @@ -0,0 +1,86 @@ +# Phase 1 Data Model: Dead Transitional Residue Cleanup + +## Overview + +This feature introduces no new table, enum, or persisted artifact. It narrows the active runtime language around two already-existing truth domains: + +1. Baseline profile lifecycle truth should flow only through `BaselineProfileStatus`. +2. Tenant app-status should remain, at most, historical stored data, not active runtime/support truth. + +## Persistent Source Truths + +### BaselineProfile + +**Purpose**: Workspace-owned baseline profile record. + +**Key fields**: +- `id` +- `workspace_id` +- `name` +- `status` +- `capture_mode` +- `active_snapshot_id` + +**Validation rules**: +- `status` is cast to `BaselineProfileStatus` and is the only active lifecycle contract for draft, active, and archived behavior. +- Deprecated alias constants on the model are not part of persistent truth and can be removed once no runtime caller depends on them. + +### Tenant + +**Purpose**: Tenant-owned lifecycle and management record. + +**Key fields**: +- `id` +- `workspace_id` +- `name` +- `status` +- `rbac_status` +- `app_status` (historical legacy field) + +**Validation rules**: +- `status` remains the active tenant lifecycle truth. +- `rbac_status` remains a separate active management truth. +- `app_status` may remain stored historically, but current runtime/support paths must not treat it as active default truth. + +## Support Artifacts In Scope + +### Deprecated alias layer + +**Artifact**: `BaselineProfile::STATUS_DRAFT`, `STATUS_ACTIVE`, `STATUS_ARCHIVED` + +**Role after cleanup**: +- removed from active runtime language + +### Legacy badge layer + +**Artifacts**: +- `BadgeDomain::TenantAppStatus` +- `BadgeCatalog` mapper entry for tenant app status +- `TenantAppStatusBadge` + +**Role after cleanup**: +- removed if no runtime consumer remains + +### Legacy default setup + +**Artifacts**: +- `TenantFactory` default `app_status => 'ok'` +- `SeedBackupHealthBrowserFixture` default `app_status => 'ok'` + +**Role after cleanup**: +- removed as ambient defaults +- legacy `app_status` becomes explicit per-test or per-scenario setup only + +## Behavioral Rules + +1. Removing dead residue must not change baseline profile archive/list/workspace behavior. +2. Removing dead residue must not change tenant lifecycle or RBAC truth behavior. +3. Tests that still need a legacy `app_status` value must set it explicitly in the scenario. +4. Historical schema and migrations remain historical artifacts, not cleanup targets in this slice. + +## No New Persistence + +- No new table is introduced. +- No new enum or reason family is introduced. +- No new derived readiness or cleanup artifact is introduced. +- No stored field is repurposed into a new active truth contract. diff --git a/specs/234-dead-transitional-residue/plan.md b/specs/234-dead-transitional-residue/plan.md new file mode 100644 index 00000000..1a0478ce --- /dev/null +++ b/specs/234-dead-transitional-residue/plan.md @@ -0,0 +1,245 @@ +# Implementation Plan: Dead Transitional Residue Cleanup + +**Branch**: `234-dead-transitional-residue` | **Date**: 2026-04-23 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/234-dead-transitional-residue/spec.md` + +**Note**: This plan keeps historical tenant `app_status` storage and historical migrations intact. It removes only dead runtime alias/support paths and tightens fixtures/tests so legacy values become explicit opt-in setup rather than ambient repo truth. + +## Summary + +Remove the dead `BaselineProfile::STATUS_*` alias layer and retire tenant app-status residue from the centralized badge catalog, default test fixtures, browser smoke seed data, and legacy-facing tests. The implementation stays intentionally small: no schema change, no new status family, no operator-surface redesign, and no compatibility shim. The proof burden is that current tenant truth and baseline profile behavior remain unchanged while the dead semantics disappear from active runtime language. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 +**Primary Dependencies**: `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests +**Storage**: Existing PostgreSQL `baseline_profiles` and `tenants` tables; no new persistence and no schema migration in this slice +**Testing**: Pest v4 feature and unit tests through Laravel Sail +**Validation Lanes**: `fast-feedback`, `confidence` +**Target Platform**: Laravel admin web application in Sail containers with admin routes under `/admin` +**Project Type**: Monorepo with one Laravel runtime in `apps/platform` and spec artifacts at repository root +**Performance Goals**: Preserve current request/query behavior; cleanup must not add runtime branching, new queries, or new UI layers +**Constraints**: No schema change, no compatibility aliases, no new badge/readiness domain, no authorization changes, no global-search broadening, and no new operator-facing surface +**Scale/Scope**: One Eloquent model, one central badge registry path, one legacy badge mapper, one tenant factory, one browser fixture command, and focused tenant/baseline regression families + +## Filament v5 Implementation Contract + +- **Livewire v4.0+ compliance**: Preserved. The cleanup does not introduce any legacy Livewire patterns and does not add new Filament component types. +- **Provider registration location**: Unchanged. Panel providers remain registered in `bootstrap/providers.php`. +- **Global search coverage**: `TenantResource` remains globally searchable and already has View/Edit pages. `BaselineProfileResource` keeps global search disabled via `$isGloballySearchable = false` and already has View/Edit pages. This cleanup adds no new global-search exposure. +- **Destructive actions**: No destructive action is introduced or changed. Existing tenant and baseline profile destructive flows remain on their current confirmation and authorization paths. +- **Asset strategy**: No new assets are planned. Deployment expectations remain unchanged, including `cd apps/platform && php artisan filament:assets` only when future work adds registered assets. +- **Testing plan**: Prove the cleanup with focused feature tests for tenant-truth continuity and baseline-profile list/view/edit/archive continuity, plus unit coverage for central badge-catalog cleanup. + +## 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` because this is a repo-hygiene cleanup that removes active residue rather than hiding it +- **Special surface test profiles**: `N/A` +- **Required tests or manual smoke**: `functional-core` +- **Exception path and spread control**: none +- **Active feature PR close-out entry**: `Guardrail` + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: no +- **Systems touched**: centralized badge registry, baseline profile status model language, tenant default fixtures, browser smoke fixture command, and focused tenant/baseline regression files +- **Shared abstractions reused**: `BaselineProfileStatus`, `BadgeCatalog`, `BadgeDomain`, and the existing tenant/baseline regression families +- **New abstraction introduced? why?**: none +- **Why the existing abstraction was sufficient or insufficient**: Existing abstractions are sufficient because this work removes dead support paths instead of creating a new semantic or presentation layer. +- **Bounded deviation / spread control**: none + +## Constitution Check + +*GATE: Passed before Phase 0 research. Re-check after Phase 1 design: still passed with no new persistence, no new semantic family, and no operator-surface expansion.* + +| Gate | Status | Plan Notes | +|------|--------|------------| +| Inventory-first / read-write separation | PASS | No Graph path, no new mutation flow, and no snapshot or restore behavior change. | +| RBAC, workspace isolation, tenant isolation | PASS | No route, policy, capability, or resource visibility behavior changes are planned. | +| Run observability / Ops-UX lifecycle | PASS | No `OperationRun` is created or modified; this cleanup is outside queued or remote execution semantics. | +| Shared pattern first | PASS | The plan simplifies the central badge path by removing an unused legacy domain rather than bypassing it with a local mapping. | +| Proportionality / no premature abstraction | PASS | The plan removes existing residue and introduces no new abstraction, enum, registry, or persistence. | +| Persisted truth / behavioral state | PASS | Historical columns and migrations remain untouched; no new state or artifact is introduced. | +| Badge semantics / Filament-native discipline | PASS | Central badge semantics remain authoritative, and no page-local or view-local replacement badge language is added. | +| Filament v5 / Livewire v4 contract | PASS | Existing resources remain unchanged in behavior; provider registration and global-search posture stay compliant. | +| Test governance | PASS | Proof stays in focused feature/unit lanes with no heavy-family promotion and a net reduction in fixture-default ambiguity. | + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Feature` for tenant-truth and baseline-profile continuity; `Unit` for central badge-catalog cleanup +- **Affected validation lanes**: `fast-feedback`, `confidence` +- **Why this lane mix is the narrowest sufficient proof**: The business truth is continuity after residue removal. Feature tests prove runtime behavior on current tenant and baseline flows, while one small unit slice proves the central badge cleanup without widening into browser or heavy-governance coverage. +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantTruthCleanupSpec179Test.php tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineProfileArchiveActionTest.php tests/Feature/Filament/BaselineProfileListFiltersTest.php tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php tests/Feature/Baselines/BaselineProfileAuthorizationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/TenantBadgesTest.php tests/Unit/Badges/BadgeCatalogTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd /Users/ahmeddarrazi/Documents/projects/wt-plattform && rg -n "BaselineProfile::STATUS_|TenantAppStatus|tenant_app_status" apps/platform/app apps/platform/tests apps/platform/database/factories && rg -n -- "app_status" apps/platform/app apps/platform/tests apps/platform/database/factories` +- **Fixture / helper / factory / seed / context cost risks**: Low to moderate. Removing the default `app_status` from `TenantFactory` and browser fixture setup can expose hidden reliance on ambient legacy values in tests or smoke commands. +- **Expensive defaults or shared helper growth introduced?**: No. The change reduces a misleading default rather than adding a new helper burden. +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: `standard-native relief` +- **Closing validation and reviewer handoff**: Reviewers should verify that removed residue had no active runtime dependency, that tenant/baseline tests still pass without ambient legacy defaults, and that no migration or new compatibility path slipped in. +- **Budget / baseline / trend follow-up**: none +- **Review-stop questions**: Did any test or fixture still need `app_status` but fail to set it explicitly? Did badge cleanup remove a still-used domain? Did alias removal trigger any runtime or static reference outside the planned scope? Did the cleanup expand into schema or route changes? +- **Escalation path**: `document-in-feature` +- **Active feature PR close-out entry**: `Guardrail` +- **Why no dedicated follow-up spec is needed**: This is routine current-release cleanup. A follow-up spec is only needed if a hidden runtime dependency forces a broader domain decision rather than simple residue removal. + +## Project Structure + +### Documentation (this feature) + +```text +specs/234-dead-transitional-residue/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +No contracts artifact is planned because this cleanup changes no route, API, or standalone logical interaction contract. + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Console/Commands/ +│ │ └── SeedBackupHealthBrowserFixture.php +│ ├── Models/ +│ │ └── BaselineProfile.php +│ └── Support/Badges/ +│ ├── BadgeCatalog.php +│ ├── BadgeDomain.php +│ └── Domains/ +│ └── TenantAppStatusBadge.php +├── database/ +│ └── factories/ +│ └── TenantFactory.php +└── tests/ + ├── Feature/ + │ ├── Baselines/ + │ │ ├── BaselineProfileArchiveActionTest.php + │ │ ├── BaselineProfileAuthorizationTest.php + │ │ └── BaselineProfileWorkspaceOwnershipTest.php + │ └── Filament/ + │ ├── BaselineProfileListFiltersTest.php + │ ├── BaselineProfileScopeV2PersistenceTest.php + │ ├── TenantLifecycleStatusDomainSeparationTest.php + │ └── TenantTruthCleanupSpec179Test.php + └── Unit/ + └── Badges/ + ├── BadgeCatalogTest.php + └── TenantBadgesTest.php +``` + +**Structure Decision**: Keep the work entirely inside the existing Laravel application in `apps/platform`. The plan updates one model, one central badge path, one default tenant factory, one browser fixture command, and focused regression files rather than touching resource layout or introducing a cleanup subsystem. + +## Complexity Tracking + +No constitutional violation is planned. No new structure is introduced, so no complexity exception is required. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| — | — | — | + +## Proportionality Review + +> No new enum, persisted entity, abstraction layer, taxonomy, or cross-domain UI framework is planned in this slice. + +- **Current operator problem**: Dead alias and legacy-support residue still make it easy for contributors and tests to treat retired semantics as current repo truth. +- **Existing structure is insufficient because**: The current code already has the canonical `BaselineProfileStatus` path and current tenant-truth behavior, but dead support artifacts continue to conserve the older semantics around them. +- **Narrowest correct implementation**: Remove only the dead alias/support paths and make any still-needed legacy values explicit in the few tests or fixtures that truly need them. +- **Ownership cost created**: Small one-time cleanup across a handful of files, followed by lower ongoing cognitive and maintenance cost. +- **Alternative intentionally rejected**: Keeping deprecated aliases, badge domains, or factory defaults for convenience was rejected because this is a pre-production repo and the residue already undermines cleanup discipline. +- **Release truth**: Current-release truth cleanup. + +## Phase 0 Research Summary + +- `BaselineProfile::STATUS_DRAFT`, `STATUS_ACTIVE`, and `STATUS_ARCHIVED` exist only in `apps/platform/app/Models/BaselineProfile.php`; no current `apps/platform` runtime or test reference still needs them. +- The tenant app-status badge path is now pure residue: `BadgeDomain::TenantAppStatus`, the `BadgeCatalog` mapper entry, and `TenantAppStatusBadge` remain, but the only confirmed consumers are badge tests, not current runtime surfaces. +- `TenantFactory` still defaults `app_status` to `ok`, and `SeedBackupHealthBrowserFixture` still writes `app_status => 'ok'`, which keeps a retired value ambient in test and smoke data even though current tenant surfaces no longer depend on it. +- Existing tenant-truth regressions intentionally set `app_status` in a few scenarios to prove suppression. Those explicit setups should remain where meaningful; only ambient defaults should go away. +- Historical migrations and historical stored columns are not part of this cleanup. The correct scope is runtime/support residue removal first, not schema deletion. + +## Phase 1 Design Summary + +- `research.md` records the cleanup decisions and rejected alternatives. +- `data-model.md` documents the still-active persistent truths and the support artifacts that should stop acting as active repo truth. +- `quickstart.md` gives the narrow validation order for alias removal, badge cleanup, fixture cleanup, and regression verification. +- No contracts artifact is created because the feature changes no route, API, or new user interaction contract. + +## Phase 1 — Agent Context Update + +Run after artifact generation: + +- `.specify/scripts/bash/update-agent-context.sh copilot` + +## Implementation Strategy + +### Phase A — Remove dead baseline profile alias language + +**Goal**: Make `BaselineProfileStatus` the only active baseline profile status contract. + +| Step | File | Change | +|------|------|--------| +| A.1 | `apps/platform/app/Models/BaselineProfile.php` | Remove deprecated `STATUS_DRAFT`, `STATUS_ACTIVE`, and `STATUS_ARCHIVED` constants. | +| A.2 | Targeted grep + baseline regression files | Confirm no runtime or test path in `apps/platform` still references the removed aliases; keep baseline profile behavior proved through existing feature tests rather than adding a new alias-specific shim. | + +### Phase B — Retire the dead tenant app-status badge path centrally + +**Goal**: Remove the last active runtime support entry point for tenant app-status semantics. + +| Step | File | Change | +|------|------|--------| +| B.1 | `apps/platform/app/Support/Badges/BadgeDomain.php` | Remove the `TenantAppStatus` enum case if no active runtime consumer remains. | +| B.2 | `apps/platform/app/Support/Badges/BadgeCatalog.php` | Remove the `TenantAppStatus` mapper registration. | +| B.3 | `apps/platform/app/Support/Badges/Domains/TenantAppStatusBadge.php` | Remove the mapper class once the registry path is gone. | +| B.4 | `apps/platform/tests/Unit/Badges/TenantBadgesTest.php` and `apps/platform/tests/Unit/Badges/BadgeCatalogTest.php` | Update badge coverage to prove the canonical tenant lifecycle/RBAC/permission domains still work and that the removed legacy domain no longer acts as active repo truth. | + +### Phase C — Make legacy app-status explicit instead of ambient in defaults and smoke data + +**Goal**: Stop silently injecting retired tenant app-status semantics into factories and browser fixture setup. + +| Step | File | Change | +|------|------|--------| +| C.1 | `apps/platform/database/factories/TenantFactory.php` | Remove the default `app_status => 'ok'` assignment so tests must opt in explicitly when they need a historical value. | +| C.2 | `apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php` | Remove or conditionalize the forced `app_status => 'ok'` write unless the scenario explicitly requires it for a still-active smoke purpose. | +| C.3 | Targeted tenant-truth tests | Keep explicit `app_status` setup only in cases that intentionally prove the legacy field no longer surfaces as truth. | + +### Phase D — Rebalance regression coverage around explicit legacy setup + +**Goal**: Preserve current behavior while making the cleanup durable. + +| Step | File | Change | +|------|------|--------| +| D.1 | `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` | Keep explicit legacy `app_status` values where they prove suppression; stop depending on factory defaults. | +| D.2 | `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` | Keep lifecycle/RBAC separation assertions intact with explicit historical setup where needed. | +| D.3 | `apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php` | Use these existing baseline profile tests as continuity proof after alias removal across archive, list/filter, view/edit, and workspace-owned behavior. | + +## Risks and Mitigations + +- **Hidden dependency on removed badge domain**: A helper or test outside the initial grep scope may still call `BadgeDomain::TenantAppStatus`. Mitigation: targeted grep before merge plus running `TenantBadgesTest` and `BadgeCatalogTest` after central removal. +- **Ambient fixture reliance on `app_status`**: Removing the factory default can reveal tests or smoke commands that only passed because `app_status` was silently set to `ok`. Mitigation: convert those cases to explicit setup rather than restoring the default. +- **Baseline alias removal reaches farther than expected**: A non-obvious reference could still exist outside the model. Mitigation: grep for `BaselineProfile::STATUS_` before merge and rely on existing baseline feature tests for continuity. +- **Cleanup scope drifts into schema deletion**: The presence of migrations and stored columns can tempt a larger cut. Mitigation: keep historical schema/migrations explicitly out of scope in both plan and tasks. + +## Post-Design Re-check + +The plan remains constitution-compliant, Livewire v4 / Filament v5 compliant, and appropriately narrow. It removes dead runtime/support residue, preserves existing tenant and baseline behavior, introduces no new persistence or abstraction, and is ready for `/speckit.tasks`. + +## Implementation Close-Out + +- **Residue search result**: Clean for active runtime/support paths. Remaining `tenant_app_status` and `BaselineProfile::class.'::STATUS_*'` matches are negative regression assertions only. +- **Remaining `app_status` usage**: Explicit historical setup and suppression assertions in tenant-truth tests only. `TenantFactory` and the backup-health browser fixture no longer write ambient `app_status` defaults. +- **Follow-up decision**: No hidden runtime dependency was found, so no follow-up cleanup spec is needed for this slice. diff --git a/specs/234-dead-transitional-residue/quickstart.md b/specs/234-dead-transitional-residue/quickstart.md new file mode 100644 index 00000000..1e736ea1 --- /dev/null +++ b/specs/234-dead-transitional-residue/quickstart.md @@ -0,0 +1,80 @@ +# Quickstart: Dead Transitional Residue Cleanup + +## Goal + +Validate that dead baseline profile alias language and dead tenant app-status support residue are removed without changing current tenant-truth or baseline-profile behavior. + +## Prerequisites + +1. Start Sail for `apps/platform`. +2. Ensure the current branch is `234-dead-transitional-residue`. +3. Be ready to update tests that intentionally use historical `app_status` values so they set those values explicitly. + +## Implementation Validation Order + +### 1. Format touched files + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +Expected outcome: +- Touched PHP and test files follow project formatting rules. + +### 2. Run tenant-truth continuity coverage + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantTruthCleanupSpec179Test.php tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php +``` + +Expected outcome: +- Tenant lifecycle and RBAC truth remain unchanged. +- Any legacy `app_status` used in those tests is explicit scenario setup, not a hidden factory default. + +### 3. Run baseline-profile continuity coverage + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineProfileArchiveActionTest.php tests/Feature/Filament/BaselineProfileListFiltersTest.php tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php tests/Feature/Baselines/BaselineProfileAuthorizationTest.php +``` + +Expected outcome: +- Baseline profile archive, list, view, and edit behavior still work after removing deprecated status aliases. + +### 4. Run central badge cleanup coverage + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/TenantBadgesTest.php tests/Unit/Badges/BadgeCatalogTest.php +``` + +Expected outcome: +- The central badge catalog still resolves active tenant badge domains correctly. +- The removed tenant app-status badge path no longer acts as active runtime truth. + +### 5. Run a focused residue grep before merge + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd /Users/ahmeddarrazi/Documents/projects/wt-plattform && rg -n "BaselineProfile::STATUS_|TenantAppStatus|tenant_app_status" apps/platform/app apps/platform/tests apps/platform/database/factories +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd /Users/ahmeddarrazi/Documents/projects/wt-plattform && rg -n -- "app_status" apps/platform/app apps/platform/tests apps/platform/database/factories +``` + +Expected outcome: +- No unexpected alias or badge-domain references remain. +- Any remaining `app_status` matches are deliberate explicit historical setup or reviewed historical artifacts, not ambient defaults or active truth. + +Implementation close-out: +- Active runtime/support paths are clean. +- Remaining `tenant_app_status` and `BaselineProfile::class.'::STATUS_*'` matches are negative regression assertions. +- Remaining `app_status` matches are explicit tenant-truth setup or suppression assertions; no follow-up spec is needed. + +## Optional Manual Smoke + +1. Open `/admin/tenants` and verify current tenant truth still behaves as before. +2. Open `/admin/baseline-profiles`, then a baseline profile view page and edit page, and verify list, view, edit, and archive behavior still read normally. +3. If the backup-health browser fixture command is still used locally, run it once and confirm it no longer depends on ambient `app_status` defaults. + +## Non-Goals For This Slice + +- No database migration. +- No route or global-search change. +- No new readiness or badge framework. +- No onboarding or provider-connection cleanup outside the approved dead-residue scope. diff --git a/specs/234-dead-transitional-residue/research.md b/specs/234-dead-transitional-residue/research.md new file mode 100644 index 00000000..38e67746 --- /dev/null +++ b/specs/234-dead-transitional-residue/research.md @@ -0,0 +1,41 @@ +# Phase 0 Research: Dead Transitional Residue Cleanup + +## Decision: Remove the deprecated `BaselineProfile::STATUS_*` aliases entirely + +**Rationale**: The only confirmed definitions of `BaselineProfile::STATUS_DRAFT`, `STATUS_ACTIVE`, and `STATUS_ARCHIVED` are in `apps/platform/app/Models/BaselineProfile.php`. No current `apps/platform` runtime or test reference still depends on those aliases. The canonical contract is already the `BaselineProfileStatus` enum cast, so keeping the constants adds dead language without serving current behavior. + +**Alternatives considered**: +- Keep the aliases but leave them deprecated: rejected because they no longer protect any active caller and continue to advertise parallel truth. +- Replace them with forwarding helpers: rejected because that would add new residue to preserve dead semantics. + +## Decision: Remove the tenant app-status badge domain from the central badge path + +**Rationale**: The remaining runtime path for tenant app-status semantics is the central badge registry: `BadgeDomain::TenantAppStatus`, the `BadgeCatalog` mapper entry, and `TenantAppStatusBadge`. Current confirmed consumers are badge tests, not active tenant surfaces. Once the legacy badge domain has no runtime consumer, removing it centrally is cleaner than keeping it as diagnostic folklore. + +**Alternatives considered**: +- Keep the badge domain as a dormant diagnostic mapping: rejected because no current runtime surface needs it and dormant central mappings make it easier to reintroduce dead semantics accidentally. +- Move the mapping into a test helper: rejected because test-only preservation would still keep the dead semantics alive as sanctioned language. + +## Decision: Remove ambient `app_status` defaults from test and smoke setup + +**Rationale**: `TenantFactory` still defaults `app_status` to `ok`, and `SeedBackupHealthBrowserFixture` still writes `app_status => 'ok'`. That keeps a retired value ambient in new tenant records and smoke data even though current tenant surfaces no longer depend on it. The safer contract is explicit legacy setup only where a test or fixture intentionally proves suppression. + +**Alternatives considered**: +- Keep the default for convenience: rejected because convenience is exactly how dead semantics keep surviving. +- Remove `app_status` from every explicit test and fixture immediately: rejected because a few tests intentionally set historical values to prove they no longer surface as truth. + +## Decision: Keep historical schema and stored fields out of scope + +**Rationale**: The repo still contains historical migrations and the stored `tenants.app_status` column. This cleanup is about active runtime/support residue, not schema deletion. Removing columns or historical migrations would widen the slice beyond the approved cleanup boundary. + +**Alternatives considered**: +- Drop the column now: rejected because the spec explicitly forbids schema work in this slice. +- Add a migration shim or deprecation wrapper: rejected because this is pre-production cleanup, not a compatibility exercise. + +## Decision: Reuse existing tenant-truth and baseline-profile regressions instead of creating a new cleanup harness + +**Rationale**: The current proof burden is continuity after residue removal. Existing tenant-truth feature tests and baseline-profile feature tests already exercise the active behavior we need to protect. A small badge-catalog unit slice is enough for the central registry cleanup. A new meta guard framework would add more long-term burden than value. + +**Alternatives considered**: +- Add grep-driven guard tests for every removed symbol: rejected because behavior-facing tests are the primary proof and repo grep is sufficient as a review aid. +- Rely on manual inspection only: rejected because cleanup regressions are easy to reintroduce silently. diff --git a/specs/234-dead-transitional-residue/spec.md b/specs/234-dead-transitional-residue/spec.md new file mode 100644 index 00000000..4034e48e --- /dev/null +++ b/specs/234-dead-transitional-residue/spec.md @@ -0,0 +1,223 @@ +# Feature Specification: Dead Transitional Residue Cleanup + +**Feature Branch**: `234-dead-transitional-residue` +**Created**: 2026-04-23 +**Status**: Draft +**Input**: User description: "Dead Transitional Residue Cleanup" + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: The repo still carries dead transitional residue around tenant truth and baseline profile status language. Deprecated baseline profile status aliases and retired tenant app-status support artifacts still survive in support code, fixtures, and tests even though the current product no longer treats them as active truth. +- **Today's failure**: Contributors and regressions can still conserve or reintroduce dead semantics because the repo keeps them available as if they were valid current-release language. That weakens earlier tenant-truth cleanup and makes follow-up cleanup strands harder to land cleanly. +- **User-visible improvement**: Existing tenant and baseline profile surfaces keep the same current truth, but retired app-status and deprecated status-alias semantics stop leaking back through defaults, badges, fixtures, and tests. +- **Smallest enterprise-capable version**: Remove dead baseline profile status aliases and tenant app-status residue from active runtime support code, factories, seeds, fixtures, and tests after verifying no productive dependency still exists. Do not redesign tenant readiness, baseline semantics, or storage. +- **Explicit non-goals**: No new readiness model, no new status family, no schema redesign, no provider-connection cleanup beyond the dead tenant app-status residue, no onboarding fallback cleanup, and no canonical operation-type convergence work. +- **Permanent complexity imported**: None. The feature reduces permanent complexity by removing dead symbols, dead badge semantics, and fixture conservatism while keeping focused regression coverage. +- **Why now**: This is the first step in the active repository cleanup strand. Leaving dead residue in place makes the next cleanup slices and source-of-truth work riskier because they must keep fighting old semantics that should already be gone. +- **Why not local**: Deleting only one constant or one test would leave the same dead semantics alive in other seams such as badge registration, factories, browser fixtures, or seed data. The problem is distributed residue, not one stray reference. +- **Approval class**: Cleanup +- **Red flags triggered**: One mild red flag: the cleanup spans model, badge, fixture, seed, and test seams. Defense: those seams all conserve the same retired semantics, so one bounded cleanup spec is smaller and safer than several micro-specs. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 2 | Produktnähe: 1 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace + tenant +- **Primary Routes**: + - `/admin/tenants` + - `/admin/tenants/{tenant}` + - `/admin/baseline-profiles` + - `/admin/baseline-profiles/{profile}` + - `/admin/baseline-profiles/{profile}/edit` +- **Data Ownership**: + - `tenants` remain the tenant-owned source of lifecycle, provider, and RBAC truth. This feature does not add, remove, or reinterpret tenant lifecycle semantics. + - `baseline_profiles` remain the workspace-owned source of baseline profile truth. This feature does not change profile lifecycle behavior; it removes deprecated alias language around that existing truth. + - No new persisted truth, mirror field, or cleanup ledger is introduced. +- **RBAC**: + - Workspace membership remains required for the affected admin resources. + - Tenant isolation and current capability checks remain unchanged. + - This feature does not broaden visibility, alter 404 versus 403 semantics, or add new authorization paths. + +## 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`)* + +N/A - no shared interaction family touched. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +N/A - no operator-facing surface change. Existing tenant and baseline profile surfaces must keep their current behavior while dead supporting residue is removed. + +## 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?**: No. +- **Current operator problem**: Dead residue keeps retired semantics available, which makes it easier for tests, fixtures, and future changes to treat them as current truth again. +- **Existing structure is insufficient because**: The existing structure is the problem; it still contains aliases and support artifacts that no longer represent active product language. +- **Narrowest correct implementation**: Remove only the dead residue that has no active runtime contract and update the focused regressions that still conserve it. +- **Ownership cost**: One bounded cleanup pass across affected support code, fixtures, and tests, followed by lower long-term cognitive and maintenance cost. +- **Alternative intentionally rejected**: Leaving deprecated aliases and legacy support artifacts in place "just in case" was rejected because this is a pre-production repo and the residue already causes semantic drift. +- **Release truth**: Current-release truth cleanup. + +### 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 proof burden is that existing tenant and baseline profile behaviors still work after dead residue is removed, and that dead semantics no longer survive in active support paths. Focused feature coverage with one small badge-regression slice is sufficient. +- **New or expanded test families**: Update the existing tenant-truth cleanup regressions, tenant lifecycle domain-separation regressions, baseline profile behavior regressions, and badge catalog regressions. Add a narrow baseline-status cleanup regression only if an existing file cannot express the assertion cleanly. +- **Fixture / helper cost impact**: Lower overall. Factories, seeds, and browser fixtures should stop carrying dead app-status defaults unless a still-active boundary proves they are needed. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: standard-native-filament +- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient. The cleanup must also prove that central badge registration and default fixtures do not conserve retired semantics. +- **Reviewer handoff**: Reviewers must confirm that no active runtime dependency still needs the removed residue, that tenant and baseline profile behavior remains unchanged for current truth, and that dead semantics are removed rather than rewrapped in a compatibility shim. +- **Budget / baseline / trend impact**: none +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantTruthCleanupSpec179Test.php tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineProfileArchiveActionTest.php tests/Feature/Filament/BaselineProfileListFiltersTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/TenantBadgesTest.php tests/Unit/Badges/BadgeCatalogTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Keep tenant truth free of retired app-status semantics (Priority: P1) + +As an operator, I can continue to use tenant surfaces without retired app-status semantics resurfacing through defaults, badges, or seeded examples, so the current lifecycle, provider, and RBAC truth stays trustworthy. + +**Why this priority**: Tenant truth cleanup already happened on primary surfaces. The highest-value part of this cleanup is making sure dead supporting residue cannot silently undo that work. + +**Independent Test**: Can be fully tested by exercising the existing tenant list and tenant detail regressions with records that still contain legacy app-status values and proving that current tenant truth stays unchanged. + +**Acceptance Scenarios**: + +1. **Given** a tenant record still stores a legacy app-status value, **When** the operator opens the existing tenant list or tenant detail surface, **Then** that legacy value does not regain current-status meaning. +2. **Given** seeded or factory-created tenant examples are used in current tenant-truth regressions, **When** those regressions run after cleanup, **Then** they no longer depend on app-status defaults to make the surfaces work. +3. **Given** lifecycle, provider, and RBAC truth already coexist on a tenant surface, **When** the cleanup is complete, **Then** those active truths remain separate and unchanged. + +--- + +### User Story 2 - Use one baseline profile status language (Priority: P1) + +As a maintainer, I can reason about baseline profile state through one canonical status contract, so draft, active, and archived behavior is not split between live status truth and deprecated aliases. + +**Why this priority**: The deprecated baseline profile aliases are explicitly dead residue. Removing them is the cleanest proof that the repo now has one active baseline profile status language. + +**Independent Test**: Can be fully tested by running existing baseline profile archive, list/filter, and view/edit continuity regressions after the deprecated alias language is removed and confirming that current baseline profile behavior stays intact. + +**Acceptance Scenarios**: + +1. **Given** baseline profiles still move through draft, active, and archived behavior today, **When** existing baseline profile regressions run after cleanup, **Then** the behavior still works without deprecated status aliases. +2. **Given** a contributor updates baseline profile logic or tests, **When** they read current profile status semantics, **Then** only the canonical status contract is available as active language. +3. **Given** an operator opens or saves an existing baseline profile through the current view and edit surfaces, **When** the cleanup is complete, **Then** those surfaces continue to render and persist through the canonical status contract without depending on deprecated aliases. + +--- + +### Cross-Cutting Verification - Prove the residue is fully retired (Release Gate) + +As a reviewer, I can verify the cleanup in one focused pass, so the repo does not keep half-dead semantics alive in support code, fixtures, or tests. + +**Why this priority**: Cleanup value is only real if the dead semantics are actually gone rather than merely hidden in one layer. + +**Release Gate**: This verification runs after User Story 1 and User Story 2 are complete and confirms that the touched runtime and test paths no longer expose the retired semantics as active language. + +**Note**: This is not an independently shippable MVP slice; it is the feature-level closeout check that proves the cleanup is complete. + +**Acceptance Scenarios**: + +1. **Given** the cleanup branch, **When** the reviewer runs the focused validation commands, **Then** current tenant and baseline profile behaviors still pass without the retired residue. +2. **Given** a touched support path, fixture, or test previously conserved dead semantics, **When** the cleanup is reviewed, **Then** that path is either removed, rewritten to current truth, or explicitly deferred as a follow-up rather than silently preserved. + +### Edge Cases + +- A legacy storage field may still exist historically or in migrations even when it no longer has any active runtime meaning. +- A browser fixture or seeder may still populate a retired value for historical realism; this spec must remove mandatory dependency on that value without changing unrelated fixture intent. +- Some tests may use literal status values rather than deprecated aliases; the cleanup must distinguish current canonical value usage from dead alias usage. +- Central badge registration may still contain dormant legacy entries even when no current surface consumes them; dormant entries count as cleanup scope if they no longer support active truth. +- Baseline profile archive, list, view, and edit behavior must continue to work because the active status contract already exists independently of the deprecated aliases. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature adds no Microsoft Graph calls, no long-running work, and no new write workflow. It is a bounded runtime and test cleanup over existing tenant and baseline profile truth. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces no new persistence, abstraction, state family, or semantic layer. It removes dead structure that no longer represents current-release truth. + +**Constitution alignment (XCUT-001):** Not applicable. The feature does not add or modify a shared operator interaction family. + +**Constitution alignment (TEST-GOV-001):** Focused feature and badge-regression coverage is the narrowest sufficient proof because the business risk is dead semantics surviving in active support paths, not a new workflow or surface family. + +**Constitution alignment (RBAC-UX):** No authorization behavior changes. Existing workspace membership, tenant isolation, and capability enforcement remain authoritative. + +**Constitution alignment (BADGE-001):** Centralized badge semantics remain authoritative. If the retired tenant app-status badge domain has no active consumer, it must be removed centrally rather than replaced with any page-local or test-local mapping. + +**Constitution alignment (UI-FIL-001):** No new Filament screen, action surface, or custom markup is introduced. Existing tenant and baseline profile surfaces must continue to rely on their current native presentation. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature removes dead interpretation residue rather than adding another semantic layer. Tests must prove current business truth survives while the residue disappears. + +### Functional Requirements + +- **FR-234-001**: The system MUST remove deprecated baseline profile status aliases from active runtime language once the cleanup proves no productive dependency remains. +- **FR-234-002**: The system MUST treat the canonical baseline profile status contract as the only active source of draft, active, and archived profile semantics. +- **FR-234-003**: The system MUST remove retired tenant app-status support residue from active badge registration, factory defaults, browser fixtures, seeds, and tests when those paths no longer serve a current runtime contract. +- **FR-234-004**: Existing tenant list and tenant detail behavior MUST remain unchanged for current lifecycle, provider, and RBAC truth after the retired residue is removed. +- **FR-234-005**: Existing baseline profile list, view, edit, and archive behavior MUST remain unchanged for current profile status truth after deprecated aliases are removed. +- **FR-234-006**: Every removed residue item MUST be checked for hidden runtime, UI, filter, cast, policy, or API dependency before deletion. +- **FR-234-007**: If a hidden dependency is found, the dependency MUST be documented and moved to a follow-up cleanup decision rather than preserving the residue as silent compatibility lore. +- **FR-234-008**: The feature MUST NOT introduce compatibility aliases, fallback readers, migration shims, or new legacy fixtures to preserve removed residue. +- **FR-234-009**: The feature MUST NOT introduce a new readiness model, new status family, new cleanup ledger, or any other replacement semantic layer. +- **FR-234-010**: Historical storage or migration remnants MAY remain only as historical artifacts and MUST NOT regain default-visible operator meaning. +- **FR-234-011**: Focused regression coverage MUST prove both tenant-truth continuity and baseline-profile continuity after the cleanup. +- **FR-234-012**: The feature MUST stay bounded to dead transitional residue cleanup and MUST NOT absorb onboarding fallback retirement, provider-connection legacy cleanup, or canonical operation-type convergence. + +### Key Entities *(include if feature involves data)* + +- **Canonical baseline profile status**: The active status language that already governs baseline profile lifecycle behavior. +- **Deprecated baseline profile status aliases**: Retired alias constants that mirror current profile statuses but no longer represent active repo truth. +- **Tenant app-status residue**: Retired support artifacts around a legacy tenant-level status signal that current tenant surfaces no longer treat as authoritative. +- **Residual support artifacts**: Factories, fixtures, seeds, tests, and badge registrations that can conserve dead semantics even when primary product surfaces no longer use them. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-234-001**: In acceptance review, no targeted tenant surface or supporting default path reintroduces tenant app-status as current truth. +- **SC-234-002**: 100% of targeted tenant-truth regressions pass after the retired tenant app-status residue is removed. +- **SC-234-003**: 100% of targeted baseline profile regressions pass after the deprecated baseline profile status aliases are removed. +- **SC-234-004**: The cleanup ships without adding any new persistence, status family, compatibility shim, or replacement semantic layer. + +## Assumptions + +- The current baseline profile status contract is already sufficient for existing profile behavior. +- Existing tenant surfaces no longer require app-status to express current tenant truth. +- Historical migration files may retain old field names or values as history without counting as active runtime truth. + +## Non-Goals + +- Dropping legacy database columns in this slice +- Redesigning tenant readiness, provider readiness, or baseline readiness semantics +- Performing onboarding fallback retirement +- Performing provider-connection legacy cleanup outside the dead tenant app-status residue +- Resolving the canonical operation-type source-of-truth conflict + +## Dependencies + +- Existing tenant lifecycle, provider, and RBAC truth separation on current tenant surfaces +- Existing baseline profile behavior and current baseline profile status contract +- Existing focused regressions for tenant truth, baseline profile behavior, and central badge registration + +## Definition of Done + +- Deprecated baseline profile status aliases are gone from active runtime language. +- Retired tenant app-status residue is gone from active badge registration, default fixtures, seeds, and tests unless an explicit still-active dependency is documented. +- Existing tenant and baseline profile behaviors remain unchanged for current truth. +- No new compatibility path or replacement status layer was introduced. +- Focused regression coverage passes. \ No newline at end of file diff --git a/specs/234-dead-transitional-residue/tasks.md b/specs/234-dead-transitional-residue/tasks.md new file mode 100644 index 00000000..f0f3103b --- /dev/null +++ b/specs/234-dead-transitional-residue/tasks.md @@ -0,0 +1,225 @@ +# Tasks: Dead Transitional Residue Cleanup + +**Input**: Design documents from `/specs/234-dead-transitional-residue/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md` + +**Tests**: Required. This feature changes runtime behavior by removing active runtime/support residue, so Pest coverage must be added or updated in `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`, `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`, `apps/platform/tests/Unit/Badges/TenantBadgesTest.php`, and `apps/platform/tests/Unit/Badges/BadgeCatalogTest.php`. +**Operations**: No new `OperationRun`, audit-only DB action, or queued workflow is introduced. This cleanup stays inside existing runtime behavior, fixture defaults, and regression coverage. +**RBAC**: No authorization semantics change. Existing tenant/admin Filament access, tenant isolation, and current `404` versus `403` behavior must remain unchanged in the touched tenant and baseline regression files. +**UI / Surface Guardrails**: No operator-facing surface is added or redesigned. Keep `standard-native-filament` relief and use the existing tenant and baseline pages only as continuity proof. +**Filament UI Action Surfaces**: No new Filament Resource, Page, RelationManager, or action surface is introduced. `TenantResource` and `BaselineProfileResource` keep their current surfaces and global-search posture. +**Badges**: `BadgeCatalog` remains authoritative. The legacy tenant app-status badge domain must be removed centrally from `apps/platform/app/Support/Badges/BadgeCatalog.php` and `apps/platform/app/Support/Badges/BadgeDomain.php`, and active badge domains must remain covered by tests. + +**Organization**: Tasks are grouped by user story so each slice stays independently testable after the shared proof surfaces are prepared. Recommended delivery order is `US1` and `US2` in parallel after Foundational, then a final cross-cutting verification phase, because the retirement proof only matters once the tenant and baseline cleanup slices are both in place. + +## 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 (Shared Cleanup Anchors) + +**Purpose**: Lock the cleanup inventory and proving commands before editing runtime or test files. + +- [X] T001 [P] Verify the cleanup anchor inventory across `apps/platform/app/Models/BaselineProfile.php`, `apps/platform/app/Support/Badges/BadgeCatalog.php`, `apps/platform/app/Support/Badges/BadgeDomain.php`, `apps/platform/app/Support/Badges/Domains/TenantAppStatusBadge.php`, `apps/platform/database/factories/TenantFactory.php`, and `apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php` +- [X] T002 [P] Verify the narrow validation lane and proving commands in `specs/234-dead-transitional-residue/plan.md` and `specs/234-dead-transitional-residue/quickstart.md` + +**Checkpoint**: Cleanup scope and proving commands are locked before code changes begin. + +--- + +## Phase 2: Foundational (Blocking Proof Surfaces) + +**Purpose**: Audit the real consumer boundaries before removing residue so story work does not rediscover hidden dependencies mid-slice. + +**CRITICAL**: No user story work should begin until this phase is complete. + +- [X] T003 [P] Audit all `TenantAppStatus` and `TenantAppStatusBadge` consumers across `apps/platform/app/Support/Badges/BadgeCatalog.php`, `apps/platform/app/Support/Badges/BadgeDomain.php`, `apps/platform/app/Support/Badges/Domains/TenantAppStatusBadge.php`, `apps/platform/tests/Unit/Badges/TenantBadgesTest.php`, and `apps/platform/tests/Unit/Badges/BadgeCatalogTest.php` +- [X] T004 [P] Audit every ambient or explicit `app_status` usage boundary across `apps/platform/database/factories/TenantFactory.php`, `apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php`, `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`, and `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` +- [X] T005 [P] Audit every `BaselineProfile::STATUS_` consumer and baseline continuity proof file across `apps/platform/app/Models/BaselineProfile.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php` +- [X] T006 [P] Lock the cross-cutting retirement proof and follow-up decision sink in `specs/234-dead-transitional-residue/plan.md`, `specs/234-dead-transitional-residue/quickstart.md`, and `specs/234-dead-transitional-residue/tasks.md` + +**Checkpoint**: Hidden-dependency boundaries are explicit and the story slices can proceed without overlapping proof setup work. + +--- + +## Phase 3: User Story 1 - Keep Tenant Truth Free Of Retired App-Status Semantics (Priority: P1) 🎯 MVP + +**Goal**: Remove tenant app-status residue from active badge/default paths without changing current tenant lifecycle, provider, or RBAC truth. + +**Independent Test**: Run the tenant-truth regressions with explicit legacy `app_status` values and verify the tenant list/detail surfaces still suppress them while active tenant truth remains unchanged. + +### Tests for User Story 1 + +- [X] T007 [P] [US1] Add tenant list/detail assertions that explicit historical `app_status` values stay suppressed in `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` +- [X] T008 [P] [US1] Add lifecycle and RBAC separation assertions that do not rely on `TenantFactory` defaults in `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` +- [X] T009 [P] [US1] Add tenant badge assertions that the legacy app-status domain no longer participates in active tenant semantics in `apps/platform/tests/Unit/Badges/TenantBadgesTest.php` + +### Implementation for User Story 1 + +- [X] T010 [P] [US1] Remove the `TenantAppStatus` registration path from `apps/platform/app/Support/Badges/BadgeDomain.php` and `apps/platform/app/Support/Badges/BadgeCatalog.php` +- [X] T011 [US1] Delete the retired mapper in `apps/platform/app/Support/Badges/Domains/TenantAppStatusBadge.php` +- [X] T012 [P] [US1] Remove the ambient `app_status` default from `apps/platform/database/factories/TenantFactory.php` +- [X] T013 [P] [US1] Remove the forced tenant `app_status` fixture value from `apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php` +- [X] T014 [US1] Reconcile explicit legacy setup and active-domain expectations in `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`, `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`, and `apps/platform/tests/Unit/Badges/TenantBadgesTest.php` + +**Checkpoint**: User Story 1 is independently functional and tenant truth no longer depends on the retired badge path or ambient `app_status` defaults. + +--- + +## Phase 4: User Story 2 - Use One Baseline Profile Status Language (Priority: P1) + +**Goal**: Remove deprecated baseline profile alias language so `BaselineProfileStatus` is the only active lifecycle contract. + +**Independent Test**: Run the existing baseline profile archive, list/filter, view/edit continuity, and workspace-ownership regressions after alias removal and verify behavior is unchanged. + +### Tests for User Story 2 + +- [X] T015 [P] [US2] Add archive-flow assertions that only `BaselineProfileStatus` drives lifecycle behavior in `apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php` +- [X] T016 [P] [US2] Add list/filter assertions that baseline profile behavior does not require alias constants in `apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php` +- [X] T017 [P] [US2] Add view/edit continuity assertions after alias removal in `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php` +- [X] T018 [P] [US2] Add workspace-ownership continuity assertions after alias removal in `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php` + +### Implementation for User Story 2 + +- [X] T019 [US2] Remove deprecated `STATUS_DRAFT`, `STATUS_ACTIVE`, and `STATUS_ARCHIVED` constants from `apps/platform/app/Models/BaselineProfile.php` +- [X] T020 [US2] Update baseline profile regressions to use only `App\Support\Baselines\BaselineProfileStatus` in `apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php` + +**Checkpoint**: User Story 2 is independently functional and baseline profile lifecycle behavior now has one canonical status language. + +--- + +## Phase 5: Cross-Cutting Verification - Prove The Residue Is Fully Retired + +**Goal**: Lock in regression proof that the retired semantics are gone from active runtime/support paths. + +**Release Gate**: Run the focused regression pack plus the residue searches after User Story 1 and User Story 2 are complete, and confirm there are no unexpected matches or hidden-default dependencies left in the touched files. + +### Verification for Cross-Cutting Closeout + +- [X] T021 [P] Add badge catalog assertions that the retired tenant app-status domain is absent while active domains remain registered in `apps/platform/tests/Unit/Badges/BadgeCatalogTest.php` +- [X] T022 [P] Add regression assertions that legacy `app_status` is always opt-in setup in `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` and `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` + +### Implementation for Cross-Cutting Closeout + +- [X] T023 Run and review the residue searches for `BaselineProfile::STATUS_|TenantAppStatus|tenant_app_status` and `app_status` across `apps/platform/app/Models/BaselineProfile.php`, `apps/platform/app/Support/Badges/BadgeCatalog.php`, `apps/platform/app/Support/Badges/BadgeDomain.php`, `apps/platform/app/Support/Badges/Domains/TenantAppStatusBadge.php`, `apps/platform/database/factories/TenantFactory.php`, `apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php`, `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`, `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php`, `apps/platform/tests/Unit/Badges/TenantBadgesTest.php`, and `apps/platform/tests/Unit/Badges/BadgeCatalogTest.php` +- [X] T024 Record any hidden-dependency follow-up or confirm clean retirement in `specs/234-dead-transitional-residue/plan.md` and `specs/234-dead-transitional-residue/quickstart.md` + +**Checkpoint**: The feature has explicit proof that the dead residue is no longer part of active truth. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finish formatting and run the narrow proving workflow for the full cleanup. + +- [X] T025 Run formatting for `apps/platform/app/Models/BaselineProfile.php`, `apps/platform/app/Support/Badges/BadgeCatalog.php`, `apps/platform/app/Support/Badges/BadgeDomain.php`, `apps/platform/database/factories/TenantFactory.php`, `apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php`, `apps/platform/tests/Unit/Badges/BadgeCatalogTest.php`, `apps/platform/tests/Unit/Badges/TenantBadgesTest.php`, `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`, `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php` with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- [X] T026 [P] Run the tenant-truth validation pack from `specs/234-dead-transitional-residue/quickstart.md` against `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` and `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` +- [X] T027 [P] Run the baseline-profile and badge validation pack from `specs/234-dead-transitional-residue/quickstart.md` against `apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`, `apps/platform/tests/Unit/Badges/TenantBadgesTest.php`, and `apps/platform/tests/Unit/Badges/BadgeCatalogTest.php` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: Starts immediately and locks the cleanup inventory plus proving commands. +- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until the hidden-dependency boundaries and closeout proof sinks are explicit. +- **User Story 1 (Phase 3)**: Depends on Foundational and is the recommended MVP cut. +- **User Story 2 (Phase 4)**: Depends on Foundational and can proceed in parallel with User Story 1 because it touches a separate runtime truth domain. +- **Cross-Cutting Verification (Phase 5)**: Depends on User Story 1 and User Story 2 because the final retirement proof only makes sense after both cleanup slices land. +- **Polish (Phase 6)**: Depends on all desired user stories and the cross-cutting verification phase being complete. + +### User Story Dependencies + +- **US1**: No dependencies beyond Foundational. +- **US2**: No dependencies beyond Foundational. + +### Within Each User Story + +- Write the story tests first and confirm they fail before implementation is considered complete. +- Keep the cleanup canonical: no compatibility aliases, no fallback readers, and no restoration of ambient legacy defaults. +- Keep `BadgeCatalog` authoritative for tenant badge semantics and `BaselineProfileStatus` authoritative for baseline lifecycle semantics. +- Finish story-level verification before moving to the next dependent slice. + +### Parallel Opportunities + +- `T001` and `T002` can run in parallel during Setup. +- `T003`, `T004`, `T005`, and `T006` can run in parallel during Foundational work. +- `T007`, `T008`, and `T009` can run in parallel for User Story 1, followed by `T010`, `T011`, `T012`, and `T013` in parallel before reconciling tests in `T014`. +- `T015`, `T016`, `T017`, and `T018` can run in parallel for User Story 2. +- User Story 1 and User Story 2 can proceed in parallel after Foundational is complete. +- `T021` and `T022` can run in parallel during cross-cutting verification. +- `T026` and `T027` can run in parallel during final validation. + +--- + +## Parallel Example: User Story 1 + +```bash +# User Story 1 tests in parallel +T007 apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php +T008 apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php +T009 apps/platform/tests/Unit/Badges/TenantBadgesTest.php + +# User Story 1 implementation in parallel after the tests are in place +T010 apps/platform/app/Support/Badges/BadgeDomain.php + apps/platform/app/Support/Badges/BadgeCatalog.php +T011 apps/platform/app/Support/Badges/Domains/TenantAppStatusBadge.php +T012 apps/platform/database/factories/TenantFactory.php +T013 apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php +``` + +## Parallel Example: User Story 2 + +```bash +# User Story 2 tests in parallel +T015 apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php +T016 apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php +T017 apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php +T018 apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php +``` + +## Parallel Example: Cross-Story Delivery After Foundational + +```bash +# Tenant cleanup and baseline cleanup can proceed in parallel after Phase 2 +T010-T014 apps/platform/app/Support/Badges/* + apps/platform/database/factories/TenantFactory.php + apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php +T019-T020 apps/platform/app/Models/BaselineProfile.php + apps/platform/tests/Feature/Baselines/* + apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php + apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.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. Run `T025` and `T026` before widening the slice. + +### Incremental Delivery + +1. Ship User Story 1 to remove tenant app-status residue from active badge/default paths. +2. Ship User Story 2 to collapse baseline lifecycle language onto `BaselineProfileStatus` only. +3. Run the cross-cutting verification phase to lock in proof that the residue is fully retired. +4. Finish with formatting and the focused validation workflow. + +### Parallel Team Strategy + +1. One contributor can prepare the badge and tenant-truth proof surfaces while another prepares the baseline continuity proof surfaces in Phase 2. +2. After Foundational is complete, one contributor can execute User Story 1 while another executes User Story 2. +3. Once both cleanup slices land, a final pass can focus on cross-cutting retirement proof and the narrow validation commands. + +--- + +## Notes + +- `[P]` tasks target different files and can be worked independently once upstream blockers are cleared. +- `[US1]` and `[US2]` map directly to the feature specification user stories. +- The suggested MVP scope is Phase 1 through Phase 3 only. +- All tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and exact file paths. -- 2.45.2 From 2752515da58dc4bceb941491e6e45c7711b0a118 Mon Sep 17 00:00:00 2001 From: ahmido Date: Fri, 24 Apr 2026 05:44:54 +0000 Subject: [PATCH 08/36] Spec 235: harden baseline truth and onboarding flows (#271) ## Summary - harden baseline capture truth, compare readiness, and monitoring explanations around latest inventory eligibility, blocked prerequisites, and zero-subject outcomes - improve onboarding verification and bootstrap recovery handling, including admin-consent callback invalidation and queued execution legitimacy/report behavior - align workspace findings/workspace overview signals and refresh the related spec, roadmap, and spec-candidate artifacts ## Validation - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php tests/Feature/Baselines/BaselineCaptureTest.php tests/Feature/Baselines/BaselineCompareFindingsTest.php tests/Feature/Baselines/BaselineSnapshotBackfillTest.php tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/AuditCoverageGovernanceTest.php tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/AdminConsentCallbackTest.php tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Onboarding/OnboardingVerificationTest.php tests/Feature/Operations/QueuedExecutionAuditTrailTest.php tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php` ## Notes - browser validation was not re-run in this pass Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/271 --- .github/agents/copilot-instructions.md | 4 +- .specify/memory/constitution.md | 50 +- .specify/templates/checklist-template.md | 19 +- .specify/templates/plan-template.md | 12 + .specify/templates/spec-template.md | 17 + .specify/templates/tasks-template.md | 5 + ...c030f6558770206a939-playwright@1.59.1.json | 2 +- ...2a20c07f69dcc0-playwright-core@1.59.1.json | 2 +- ...67f2a7d955f1a8c79d154b7-rollup@4.60.2.json | 2 +- ...fb812ffb9b71917d3976-color-name@1.1.4.json | 2 +- ...4a258752289abbfa5-@types+estree@1.0.8.json | 2 +- ...ce5601b8d3ff5c58a19202ec50-rxjs@7.8.2.json | 2 +- ...98bb0e03f9c087a6fa107d300-tslib@2.8.1.json | 2 +- ...00e365fe3798d4da3cbaa074-axios@1.15.0.json | 2 +- .../ManagedTenantOnboardingWizard.php | 308 ++++- .../Resources/BaselineProfileResource.php | 37 +- .../Pages/ViewBaselineProfile.php | 14 +- .../AdminConsentCallbackController.php | 48 + .../app/Jobs/CaptureBaselineSnapshotJob.php | 330 +++++- .../app/Livewire/BulkOperationProgress.php | 3 +- .../Notifications/OperationRunCompleted.php | 5 + .../Baselines/BaselineCaptureService.php | 147 +++ .../FindingAssignmentHygieneService.php | 53 +- .../app/Services/OperationRunService.php | 16 +- .../QueuedExecutionLegitimacyGate.php | 53 +- .../Baselines/BaselineCompareStats.php | 33 +- .../Support/Baselines/BaselineReasonCodes.php | 27 +- .../platform/app/Support/OpsUx/ActiveRuns.php | 2 +- .../GovernanceRunDiagnosticSummaryBuilder.php | 75 ++ .../Support/OpsUx/OperationUxPresenter.php | 24 + .../ReasonTranslation/ReasonPresenter.php | 1 + .../ReasonTranslation/ReasonTranslator.php | 77 +- .../Workspaces/WorkspaceOverviewBuilder.php | 34 +- ...ycle_state_to_baseline_snapshots_table.php | 17 +- .../views/admin-consent-callback.blade.php | 2 +- ...ec172DeferredOperatorSurfacesSmokeTest.php | 2 +- .../Feature/AdminConsentCallbackTest.php | 57 + ...torExplanationSurfaceAuthorizationTest.php | 30 + .../BaselineCaptureAuditEventsTest.php | 4 + ...aselineSnapshotNoTenantIdentifiersTest.php | 9 +- .../CaptureBaselineContentTest.php | 4 + ...CaptureBaselineFullContentOnDemandTest.php | 4 + .../CaptureBaselineMetaFallbackTest.php | 9 +- .../Feature/Baselines/BaselineCaptureTest.php | 270 ++++- .../Baselines/BaselineCompareFindingsTest.php | 19 +- .../BaselineSnapshotBackfillTest.php | 7 +- ...ineCaptureResultExplanationSurfaceTest.php | 28 + ...BaselineCompareLandingStartSurfaceTest.php | 90 ++ ...BaselineProfileCaptureStartSurfaceTest.php | 28 + .../OperationRunBaselineTruthSurfaceTest.php | 46 +- .../Filament/WorkspaceOverviewDbOnlyTest.php | 2 +- ...c194GovernanceActionSemanticsGuardTest.php | 2 +- .../ManagedTenantOnboardingWizardTest.php | 384 +++++- .../AuditCoverageGovernanceTest.php | 56 + .../GovernanceOperationRunSummariesTest.php | 75 ++ .../OperationRunNotificationTest.php | 70 ++ .../Onboarding/OnboardingVerificationTest.php | 110 ++ .../QueuedExecutionAuditTrailTest.php | 10 +- .../QueuedExecutionLegitimacyGateTest.php | 91 ++ docs/product/roadmap.md | 30 +- docs/product/spec-candidates.md | 1041 ++++++++++++++++- .../checklists/requirements.md | 36 + .../235-baseline-capture-truth/data-model.md | 164 +++ specs/235-baseline-capture-truth/plan.md | 267 +++++ .../235-baseline-capture-truth/quickstart.md | 164 +++ specs/235-baseline-capture-truth/research.md | 55 + specs/235-baseline-capture-truth/spec.md | 264 +++++ specs/235-baseline-capture-truth/tasks.md | 231 ++++ 68 files changed, 4871 insertions(+), 217 deletions(-) create mode 100644 specs/235-baseline-capture-truth/checklists/requirements.md create mode 100644 specs/235-baseline-capture-truth/data-model.md create mode 100644 specs/235-baseline-capture-truth/plan.md create mode 100644 specs/235-baseline-capture-truth/quickstart.md create mode 100644 specs/235-baseline-capture-truth/research.md create mode 100644 specs/235-baseline-capture-truth/spec.md create mode 100644 specs/235-baseline-capture-truth/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 0951743d..c295f3a6 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -246,6 +246,8 @@ ## Active Technologies - Existing PostgreSQL `operation_runs` records and current session/query-backed monitoring navigation state; no new persistence (233-stale-run-visibility) - PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests (234-dead-transitional-residue) - Existing PostgreSQL `baseline_profiles` and `tenants` tables; no new persistence and no schema migration in this slice (234-dead-transitional-residue) +- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces (235-baseline-capture-truth) +- Existing PostgreSQL tables only; no new table or schema migration is planned in the mainline slice (235-baseline-capture-truth) - PHP 8.4.15 (feat/005-bulk-operations) @@ -280,9 +282,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 235-baseline-capture-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces - 234-dead-transitional-residue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests - 233-stale-run-visibility: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks` -- 232-operation-run-link-contract: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers ### Pre-production compatibility check diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index dd1f054b..c4431b04 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,32 +1,28 @@ ### Pre-production compatibility check diff --git a/.specify/scripts/bash/setup-plan.sh b/.specify/scripts/bash/setup-plan.sh index d01c6d6c..48b3b6e3 100755 --- a/.specify/scripts/bash/setup-plan.sh +++ b/.specify/scripts/bash/setup-plan.sh @@ -40,9 +40,13 @@ mkdir -p "$FEATURE_DIR" TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md" if [[ -f "$TEMPLATE" ]]; then cp "$TEMPLATE" "$IMPL_PLAN" - echo "Copied plan template to $IMPL_PLAN" + if ! $JSON_MODE; then + echo "Copied plan template to $IMPL_PLAN" + fi else - echo "Warning: Plan template not found at $TEMPLATE" + if ! $JSON_MODE; then + echo "Warning: Plan template not found at $TEMPLATE" + fi # Create a basic plan file if template doesn't exist touch "$IMPL_PLAN" fi diff --git a/apps/platform/app/Models/EvidenceSnapshotItem.php b/apps/platform/app/Models/EvidenceSnapshotItem.php index 6b783585..aec2a9e4 100644 --- a/apps/platform/app/Models/EvidenceSnapshotItem.php +++ b/apps/platform/app/Models/EvidenceSnapshotItem.php @@ -45,4 +45,17 @@ public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); } + + /** + * @return list> + */ + public function canonicalControlReferences(): array + { + $payload = is_array($this->summary_payload) ? $this->summary_payload : []; + $references = $payload['canonical_controls'] ?? []; + + return is_array($references) + ? array_values(array_filter($references, static fn (mixed $reference): bool => is_array($reference))) + : []; + } } diff --git a/apps/platform/app/Models/TenantReview.php b/apps/platform/app/Models/TenantReview.php index f65b074b..a64650be 100644 --- a/apps/platform/app/Models/TenantReview.php +++ b/apps/platform/app/Models/TenantReview.php @@ -192,4 +192,17 @@ public function publishBlockers(): array return is_array($blockers) ? array_values(array_map('strval', $blockers)) : []; } + + /** + * @return list> + */ + public function canonicalControlReferences(): array + { + $summary = is_array($this->summary) ? $this->summary : []; + $references = $summary['canonical_controls'] ?? []; + + return is_array($references) + ? array_values(array_filter($references, static fn (mixed $reference): bool => is_array($reference))) + : []; + } } diff --git a/apps/platform/app/Services/Evidence/EvidenceSnapshotService.php b/apps/platform/app/Services/Evidence/EvidenceSnapshotService.php index c4b4be40..945fc1dd 100644 --- a/apps/platform/app/Services/Evidence/EvidenceSnapshotService.php +++ b/apps/platform/app/Services/Evidence/EvidenceSnapshotService.php @@ -219,6 +219,9 @@ public function buildSnapshotPayload(Tenant $tenant): array 'finding_report_buckets' => is_array($findingsSummary['report_bucket_counts'] ?? null) ? $findingsSummary['report_bucket_counts'] : [], + 'canonical_controls' => is_array($findingsSummary['canonical_controls'] ?? null) + ? $findingsSummary['canonical_controls'] + : [], 'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : [ diff --git a/apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php b/apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php index e70bcfc5..90b7b300 100644 --- a/apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php +++ b/apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php @@ -10,12 +10,15 @@ use App\Services\Findings\FindingRiskGovernanceResolver; use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Evidence\EvidenceCompletenessState; +use App\Support\Governance\Controls\CanonicalControlResolutionRequest; +use App\Support\Governance\Controls\CanonicalControlResolver; final class FindingsSummarySource implements EvidenceSourceProvider { public function __construct( private readonly FindingRiskGovernanceResolver $governanceResolver, private readonly FindingOutcomeSemantics $findingOutcomeSemantics, + private readonly CanonicalControlResolver $canonicalControlResolver, ) {} public function key(): string @@ -36,6 +39,7 @@ public function collect(Tenant $tenant): array $governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException); $governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException); $outcome = $this->findingOutcomeSemantics->describe($finding); + $canonicalControlResolution = $this->canonicalControlResolutionFor($finding); return [ 'id' => (int) $finding->getKey(), @@ -57,6 +61,7 @@ public function collect(Tenant $tenant): array 'report_bucket' => $outcome['report_bucket'], 'governance_state' => $governanceState, ] : null, + 'canonical_control_resolution' => $canonicalControlResolution, 'governance_state' => $governanceState, 'governance_warning' => $governanceWarning, ]; @@ -81,6 +86,12 @@ public function collect(Tenant $tenant): array $reportBucketCounts[$reportBucket]++; } } + $canonicalControls = $entries + ->map(static fn (array $entry): mixed => data_get($entry, 'canonical_control_resolution.control')) + ->filter(static fn (mixed $control): bool => is_array($control) && filled($control['control_key'] ?? null)) + ->unique(static fn (array $control): string => (string) $control['control_key']) + ->values() + ->all(); $riskAcceptedEntries = $entries->filter( static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED, @@ -115,6 +126,7 @@ public function collect(Tenant $tenant): array ], 'outcome_counts' => $outcomeCounts, 'report_bucket_counts' => $reportBucketCounts, + 'canonical_controls' => $canonicalControls, 'entries' => $entries->all(), ]; @@ -133,4 +145,68 @@ public function collect(Tenant $tenant): array 'sort_order' => 10, ]; } + + /** + * @return array + */ + private function canonicalControlResolutionFor(Finding $finding): array + { + return $this->canonicalControlResolver + ->resolve($this->resolutionRequestFor($finding)) + ->toArray(); + } + + private function resolutionRequestFor(Finding $finding): CanonicalControlResolutionRequest + { + $evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : []; + $findingType = (string) $finding->finding_type; + + if ($findingType === Finding::FINDING_TYPE_PERMISSION_POSTURE) { + return new CanonicalControlResolutionRequest( + provider: 'microsoft', + consumerContext: 'evidence', + subjectFamilyKey: 'permission_posture', + workload: 'entra', + signalKey: 'permission_posture.required_graph_permission', + ); + } + + if ($findingType === Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES) { + $roleTemplateId = (string) ($evidence['role_template_id'] ?? ''); + + return new CanonicalControlResolutionRequest( + provider: 'microsoft', + consumerContext: 'evidence', + subjectFamilyKey: 'entra_admin_roles', + workload: 'entra', + signalKey: $roleTemplateId === '62e90394-69f5-4237-9190-012177145e10' + ? 'entra_admin_roles.global_admin_assignment' + : 'entra_admin_roles.privileged_role_assignment', + ); + } + + if ($findingType === Finding::FINDING_TYPE_DRIFT) { + $policyType = is_string($evidence['policy_type'] ?? null) && trim((string) $evidence['policy_type']) !== '' + ? trim((string) $evidence['policy_type']) + : 'drift'; + + return new CanonicalControlResolutionRequest( + provider: 'microsoft', + consumerContext: 'evidence', + subjectFamilyKey: $policyType, + workload: 'intune', + signalKey: match ($policyType) { + 'deviceCompliancePolicy' => 'intune.device_compliance_policy', + 'drift' => 'finding.drift', + default => 'intune.device_configuration_drift', + }, + ); + } + + return new CanonicalControlResolutionRequest( + provider: 'microsoft', + consumerContext: 'evidence', + subjectFamilyKey: $findingType, + ); + } } diff --git a/apps/platform/app/Services/TenantReviews/TenantReviewComposer.php b/apps/platform/app/Services/TenantReviews/TenantReviewComposer.php index 1ee97330..b4dec8c0 100644 --- a/apps/platform/app/Services/TenantReviews/TenantReviewComposer.php +++ b/apps/platform/app/Services/TenantReviews/TenantReviewComposer.php @@ -65,6 +65,9 @@ public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null 'finding_report_buckets' => is_array(data_get($sections, '0.summary_payload.finding_report_buckets')) ? data_get($sections, '0.summary_payload.finding_report_buckets') : [], + 'canonical_controls' => is_array(data_get($sections, '0.summary_payload.canonical_controls')) + ? data_get($sections, '0.summary_payload.canonical_controls') + : [], 'report_count' => 2, 'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0), 'highlights' => data_get($sections, '0.render_payload.highlights', []), diff --git a/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php b/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php index d340e1a1..7abf7599 100644 --- a/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php +++ b/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php @@ -55,6 +55,7 @@ private function executiveSummarySection( $findingOutcomes = is_array($findingsSummary['outcome_counts'] ?? null) ? $findingsSummary['outcome_counts'] : []; $findingReportBuckets = is_array($findingsSummary['report_bucket_counts'] ?? null) ? $findingsSummary['report_bucket_counts'] : []; $riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : []; + $canonicalControls = is_array($findingsSummary['canonical_controls'] ?? null) ? $findingsSummary['canonical_controls'] : []; $openCount = (int) ($findingsSummary['open_count'] ?? 0); $findingCount = (int) ($findingsSummary['count'] ?? 0); @@ -70,6 +71,7 @@ private function executiveSummarySection( $postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.', sprintf('%d baseline drift findings remain open.', $driftCount), sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations), + $canonicalControls !== [] ? sprintf('%d canonical controls are referenced by the findings evidence.', count($canonicalControls)) : null, sprintf('%d risk-accepted findings are currently governed.', (int) ($riskAcceptance['valid_governed_count'] ?? 0)), sprintf('%d privileged Entra roles are captured in the evidence basis.', (int) ($rolesSummary['role_count'] ?? 0)), ])); @@ -96,6 +98,8 @@ private function executiveSummarySection( 'baseline_drift_count' => $driftCount, 'failed_operation_count' => $operationFailures, 'partial_operation_count' => $partialOperations, + 'canonical_control_count' => count($canonicalControls), + 'canonical_controls' => $canonicalControls, 'risk_acceptance' => $riskAcceptance, ], 'render_payload' => [ @@ -145,6 +149,7 @@ private function openRisksSection(?EvidenceSnapshotItem $findingsItem): array 'summary_payload' => [ 'open_count' => (int) ($summary['open_count'] ?? 0), 'severity_counts' => is_array($summary['severity_counts'] ?? null) ? $summary['severity_counts'] : [], + 'canonical_controls' => $this->canonicalControlsFromEntries($entries), ], 'render_payload' => [ 'entries' => $entries, @@ -178,6 +183,7 @@ private function acceptedRisksSection(?EvidenceSnapshotItem $findingsItem): arra 'expired_count' => (int) ($riskAcceptance['expired_count'] ?? 0), 'revoked_count' => (int) ($riskAcceptance['revoked_count'] ?? 0), 'missing_exception_count' => (int) ($riskAcceptance['missing_exception_count'] ?? 0), + 'canonical_controls' => $this->canonicalControlsFromEntries($entries), ], 'render_payload' => [ 'entries' => $entries, @@ -293,6 +299,20 @@ private function sourceFingerprint(?EvidenceSnapshotItem $item): ?string return is_string($fingerprint) && $fingerprint !== '' ? $fingerprint : null; } + /** + * @param list> $entries + * @return list> + */ + private function canonicalControlsFromEntries(array $entries): array + { + return collect($entries) + ->map(static fn (array $entry): mixed => data_get($entry, 'canonical_control_resolution.control')) + ->filter(static fn (mixed $control): bool => is_array($control) && filled($control['control_key'] ?? null)) + ->unique(static fn (array $control): string => (string) $control['control_key']) + ->values() + ->all(); + } + /** * @param array $states */ diff --git a/apps/platform/app/Support/Governance/Controls/ArtifactSuitability.php b/apps/platform/app/Support/Governance/Controls/ArtifactSuitability.php new file mode 100644 index 00000000..8a6f0c0c --- /dev/null +++ b/apps/platform/app/Support/Governance/Controls/ArtifactSuitability.php @@ -0,0 +1,66 @@ + $data + */ + public static function fromArray(array $data): self + { + foreach (self::requiredKeys() as $key) { + if (! array_key_exists($key, $data)) { + throw new InvalidArgumentException(sprintf('Canonical control artifact suitability is missing [%s].', $key)); + } + } + + return new self( + baseline: (bool) $data['baseline'], + drift: (bool) $data['drift'], + finding: (bool) $data['finding'], + exception: (bool) $data['exception'], + evidence: (bool) $data['evidence'], + review: (bool) $data['review'], + report: (bool) $data['report'], + ); + } + + /** + * @return array{baseline: bool, drift: bool, finding: bool, exception: bool, evidence: bool, review: bool, report: bool} + */ + public function toArray(): array + { + return [ + 'baseline' => $this->baseline, + 'drift' => $this->drift, + 'finding' => $this->finding, + 'exception' => $this->exception, + 'evidence' => $this->evidence, + 'review' => $this->review, + 'report' => $this->report, + ]; + } + + /** + * @return list + */ + public static function requiredKeys(): array + { + return ['baseline', 'drift', 'finding', 'exception', 'evidence', 'review', 'report']; + } +} diff --git a/apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php b/apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php new file mode 100644 index 00000000..f93391d6 --- /dev/null +++ b/apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php @@ -0,0 +1,126 @@ + + */ + private array $definitions; + + /** + * @var list + */ + private array $microsoftBindings; + + /** + * @param list>|null $controls + */ + public function __construct(?array $controls = null) + { + $controls ??= config('canonical_controls.controls', []); + + if (! is_array($controls)) { + throw new InvalidArgumentException('Canonical controls config must define a controls array.'); + } + + $this->definitions = []; + $this->microsoftBindings = []; + + foreach ($controls as $control) { + if (! is_array($control)) { + throw new InvalidArgumentException('Canonical control entries must be arrays.'); + } + + $definition = CanonicalControlDefinition::fromArray($control); + + if ($this->find($definition->controlKey) instanceof CanonicalControlDefinition) { + throw new InvalidArgumentException(sprintf('Duplicate canonical control key [%s].', $definition->controlKey)); + } + + $this->definitions[] = $definition; + + $bindings = is_array($control['microsoft_bindings'] ?? null) ? $control['microsoft_bindings'] : []; + + foreach ($bindings as $binding) { + if (! is_array($binding)) { + throw new InvalidArgumentException(sprintf('Microsoft bindings for [%s] must be arrays.', $definition->controlKey)); + } + + $this->microsoftBindings[] = MicrosoftSubjectBinding::fromArray($definition->controlKey, $binding); + } + } + + usort( + $this->definitions, + static fn (CanonicalControlDefinition $left, CanonicalControlDefinition $right): int => $left->controlKey <=> $right->controlKey, + ); + } + + /** + * @return list + */ + public function all(): array + { + return $this->definitions; + } + + /** + * @return list + */ + public function active(): array + { + return array_values(array_filter( + $this->definitions, + static fn (CanonicalControlDefinition $definition): bool => ! $definition->isRetired(), + )); + } + + public function find(string $controlKey): ?CanonicalControlDefinition + { + $controlKey = trim($controlKey); + + foreach ($this->definitions as $definition) { + if ($definition->controlKey === $controlKey) { + return $definition; + } + } + + return null; + } + + /** + * @return list + */ + public function microsoftBindings(): array + { + return $this->microsoftBindings; + } + + /** + * @return list + */ + public function microsoftBindingsForControl(string $controlKey): array + { + return array_values(array_filter( + $this->microsoftBindings, + static fn (MicrosoftSubjectBinding $binding): bool => $binding->controlKey === trim($controlKey), + )); + } + + /** + * @return list> + */ + public function listPayload(): array + { + return array_map( + static fn (CanonicalControlDefinition $definition): array => $definition->toArray(), + $this->all(), + ); + } +} diff --git a/apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php b/apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php new file mode 100644 index 00000000..adebed49 --- /dev/null +++ b/apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php @@ -0,0 +1,131 @@ + $evidenceArchetypes + */ + public function __construct( + public string $controlKey, + public string $name, + public string $domainKey, + public string $subdomainKey, + public string $controlClass, + public string $summary, + public string $operatorDescription, + public DetectabilityClass $detectabilityClass, + public EvaluationStrategy $evaluationStrategy, + public array $evidenceArchetypes, + public ArtifactSuitability $artifactSuitability, + public string $historicalStatus = 'active', + ) { + foreach ([ + 'control key' => $this->controlKey, + 'name' => $this->name, + 'domain key' => $this->domainKey, + 'subdomain key' => $this->subdomainKey, + 'control class' => $this->controlClass, + 'summary' => $this->summary, + 'operator description' => $this->operatorDescription, + 'historical status' => $this->historicalStatus, + ] as $label => $value) { + if (trim($value) === '') { + throw new InvalidArgumentException(sprintf('Canonical control definitions require a non-empty %s.', $label)); + } + } + + if ($this->controlKey !== mb_strtolower($this->controlKey) || preg_match('/^[a-z][a-z0-9_]*$/', $this->controlKey) !== 1) { + throw new InvalidArgumentException(sprintf('Canonical control key [%s] must be a lowercase provider-neutral slug.', $this->controlKey)); + } + + if (! in_array($this->historicalStatus, ['active', 'retired'], true)) { + throw new InvalidArgumentException(sprintf('Canonical control [%s] has an unsupported historical status.', $this->controlKey)); + } + + if ($this->evidenceArchetypes === []) { + throw new InvalidArgumentException(sprintf('Canonical control [%s] must declare at least one evidence archetype.', $this->controlKey)); + } + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + controlKey: (string) ($data['control_key'] ?? ''), + name: (string) ($data['name'] ?? ''), + domainKey: (string) ($data['domain_key'] ?? ''), + subdomainKey: (string) ($data['subdomain_key'] ?? ''), + controlClass: (string) ($data['control_class'] ?? ''), + summary: (string) ($data['summary'] ?? ''), + operatorDescription: (string) ($data['operator_description'] ?? ''), + detectabilityClass: DetectabilityClass::from((string) ($data['detectability_class'] ?? '')), + evaluationStrategy: EvaluationStrategy::from((string) ($data['evaluation_strategy'] ?? '')), + evidenceArchetypes: self::evidenceArchetypes($data['evidence_archetypes'] ?? []), + artifactSuitability: ArtifactSuitability::fromArray(is_array($data['artifact_suitability'] ?? null) ? $data['artifact_suitability'] : []), + historicalStatus: (string) ($data['historical_status'] ?? 'active'), + ); + } + + /** + * @return array{ + * control_key: string, + * name: string, + * domain_key: string, + * subdomain_key: string, + * control_class: string, + * summary: string, + * operator_description: string, + * detectability_class: string, + * evaluation_strategy: string, + * evidence_archetypes: list, + * artifact_suitability: array{baseline: bool, drift: bool, finding: bool, exception: bool, evidence: bool, review: bool, report: bool}, + * historical_status: string + * } + */ + public function toArray(): array + { + return [ + 'control_key' => $this->controlKey, + 'name' => $this->name, + 'domain_key' => $this->domainKey, + 'subdomain_key' => $this->subdomainKey, + 'control_class' => $this->controlClass, + 'summary' => $this->summary, + 'operator_description' => $this->operatorDescription, + 'detectability_class' => $this->detectabilityClass->value, + 'evaluation_strategy' => $this->evaluationStrategy->value, + 'evidence_archetypes' => array_map( + static fn (EvidenceArchetype $archetype): string => $archetype->value, + $this->evidenceArchetypes, + ), + 'artifact_suitability' => $this->artifactSuitability->toArray(), + 'historical_status' => $this->historicalStatus, + ]; + } + + public function isRetired(): bool + { + return $this->historicalStatus === 'retired'; + } + + /** + * @param iterable $values + * @return list + */ + private static function evidenceArchetypes(iterable $values): array + { + return collect($values) + ->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '') + ->map(static fn (string $value): EvidenceArchetype => EvidenceArchetype::from(trim($value))) + ->values() + ->all(); + } +} diff --git a/apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionRequest.php b/apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionRequest.php new file mode 100644 index 00000000..d415d397 --- /dev/null +++ b/apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionRequest.php @@ -0,0 +1,65 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + provider: self::normalize((string) ($data['provider'] ?? '')), + consumerContext: self::normalize((string) ($data['consumer_context'] ?? '')), + subjectFamilyKey: self::optionalString($data['subject_family_key'] ?? null), + workload: self::optionalString($data['workload'] ?? null), + signalKey: self::optionalString($data['signal_key'] ?? null), + ); + } + + public function hasDiscriminator(): bool + { + return $this->subjectFamilyKey !== null || $this->workload !== null || $this->signalKey !== null; + } + + /** + * @return array{provider: string, subject_family_key: ?string, workload: ?string, signal_key: ?string, consumer_context: string} + */ + public function bindingContext(): array + { + return [ + 'provider' => $this->provider, + 'subject_family_key' => $this->subjectFamilyKey, + 'workload' => $this->workload, + 'signal_key' => $this->signalKey, + 'consumer_context' => $this->consumerContext, + ]; + } + + private static function optionalString(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + $normalized = self::normalize($value); + + return $normalized === '' ? null : $normalized; + } + + private static function normalize(string $value): string + { + return trim($value); + } +} diff --git a/apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionResult.php b/apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionResult.php new file mode 100644 index 00000000..dd40607e --- /dev/null +++ b/apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionResult.php @@ -0,0 +1,88 @@ + $candidateControlKeys + */ + private function __construct( + public string $status, + public ?CanonicalControlDefinition $control, + public ?string $reasonCode, + public array $bindingContext, + public array $candidateControlKeys = [], + ) {} + + public static function resolved(CanonicalControlDefinition $definition): self + { + return new self( + status: 'resolved', + control: $definition, + reasonCode: null, + bindingContext: [], + ); + } + + public static function unresolved(string $reasonCode, CanonicalControlResolutionRequest $request): self + { + return new self( + status: 'unresolved', + control: null, + reasonCode: $reasonCode, + bindingContext: $request->bindingContext(), + ); + } + + /** + * @param list $candidateControlKeys + */ + public static function ambiguous(array $candidateControlKeys, CanonicalControlResolutionRequest $request): self + { + sort($candidateControlKeys, SORT_STRING); + + return new self( + status: 'ambiguous', + control: null, + reasonCode: 'ambiguous_binding', + bindingContext: $request->bindingContext(), + candidateControlKeys: array_values(array_unique($candidateControlKeys)), + ); + } + + public function isResolved(): bool + { + return $this->status === 'resolved' && $this->control instanceof CanonicalControlDefinition; + } + + /** + * @return array + */ + public function toArray(): array + { + if ($this->isResolved()) { + return [ + 'status' => 'resolved', + 'control' => $this->control?->toArray(), + ]; + } + + if ($this->status === 'ambiguous') { + return [ + 'status' => 'ambiguous', + 'reason_code' => $this->reasonCode, + 'candidate_control_keys' => $this->candidateControlKeys, + 'binding_context' => $this->bindingContext, + ]; + } + + return [ + 'status' => 'unresolved', + 'reason_code' => $this->reasonCode, + 'binding_context' => $this->bindingContext, + ]; + } +} diff --git a/apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php b/apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php new file mode 100644 index 00000000..4fbdc71b --- /dev/null +++ b/apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php @@ -0,0 +1,69 @@ + + */ + private const SUPPORTED_CONTEXTS = ['baseline', 'drift', 'finding', 'evidence', 'exception', 'review', 'report']; + + public function __construct( + private CanonicalControlCatalog $catalog, + ) {} + + public function resolve(CanonicalControlResolutionRequest $request): CanonicalControlResolutionResult + { + if ($request->provider !== 'microsoft') { + return CanonicalControlResolutionResult::unresolved('unsupported_provider', $request); + } + + if (! in_array($request->consumerContext, self::SUPPORTED_CONTEXTS, true)) { + return CanonicalControlResolutionResult::unresolved('unsupported_consumer_context', $request); + } + + if (! $request->hasDiscriminator()) { + return CanonicalControlResolutionResult::unresolved('insufficient_context', $request); + } + + $bindings = array_values(array_filter( + $this->catalog->microsoftBindings(), + static fn (MicrosoftSubjectBinding $binding): bool => $binding->matches($request), + )); + + if ($bindings === []) { + return CanonicalControlResolutionResult::unresolved('missing_binding', $request); + } + + $primaryBindings = array_values(array_filter( + $bindings, + static fn (MicrosoftSubjectBinding $binding): bool => $binding->primary, + )); + + if ($primaryBindings !== []) { + $bindings = $primaryBindings; + } + + $candidateControlKeys = array_values(array_unique(array_map( + static fn (MicrosoftSubjectBinding $binding): string => $binding->controlKey, + $bindings, + ))); + + sort($candidateControlKeys, SORT_STRING); + + if (count($candidateControlKeys) !== 1) { + return CanonicalControlResolutionResult::ambiguous($candidateControlKeys, $request); + } + + $definition = $this->catalog->find($candidateControlKeys[0]); + + if (! $definition instanceof CanonicalControlDefinition) { + return CanonicalControlResolutionResult::unresolved('missing_binding', $request); + } + + return CanonicalControlResolutionResult::resolved($definition); + } +} diff --git a/apps/platform/app/Support/Governance/Controls/DetectabilityClass.php b/apps/platform/app/Support/Governance/Controls/DetectabilityClass.php new file mode 100644 index 00000000..3874512d --- /dev/null +++ b/apps/platform/app/Support/Governance/Controls/DetectabilityClass.php @@ -0,0 +1,13 @@ + $signalKeys + * @param list $supportedContexts + */ + public function __construct( + public string $controlKey, + public ?string $subjectFamilyKey, + public ?string $workload, + public array $signalKeys, + public array $supportedContexts, + public bool $primary = false, + public ?string $notes = null, + ) { + if (trim($this->controlKey) === '') { + throw new InvalidArgumentException('Microsoft subject bindings require a canonical control key.'); + } + + if ($this->subjectFamilyKey === null && $this->workload === null && $this->signalKeys === []) { + throw new InvalidArgumentException(sprintf('Microsoft subject binding for [%s] requires at least one discriminator.', $this->controlKey)); + } + + if ($this->supportedContexts === []) { + throw new InvalidArgumentException(sprintf('Microsoft subject binding for [%s] requires at least one supported context.', $this->controlKey)); + } + } + + /** + * @param array $data + */ + public static function fromArray(string $controlKey, array $data): self + { + return new self( + controlKey: $controlKey, + subjectFamilyKey: self::optionalString($data['subject_family_key'] ?? null), + workload: self::optionalString($data['workload'] ?? null), + signalKeys: self::stringList($data['signal_keys'] ?? []), + supportedContexts: self::stringList($data['supported_contexts'] ?? []), + primary: (bool) ($data['primary'] ?? false), + notes: self::optionalString($data['notes'] ?? null), + ); + } + + public function supportsContext(string $consumerContext): bool + { + return in_array(trim($consumerContext), $this->supportedContexts, true); + } + + public function matches(CanonicalControlResolutionRequest $request): bool + { + if ($request->provider !== 'microsoft') { + return false; + } + + if (! $this->supportsContext($request->consumerContext)) { + return false; + } + + if ($request->subjectFamilyKey !== null && $this->subjectFamilyKey !== $request->subjectFamilyKey) { + return false; + } + + if ($request->workload !== null && $this->workload !== $request->workload) { + return false; + } + + if ($request->signalKey !== null && ! in_array($request->signalKey, $this->signalKeys, true)) { + return false; + } + + return true; + } + + /** + * @return array{ + * control_key: string, + * provider: string, + * subject_family_key: ?string, + * workload: ?string, + * signal_keys: list, + * supported_contexts: list, + * primary: bool, + * notes: ?string + * } + */ + public function toArray(): array + { + return [ + 'control_key' => $this->controlKey, + 'provider' => 'microsoft', + 'subject_family_key' => $this->subjectFamilyKey, + 'workload' => $this->workload, + 'signal_keys' => $this->signalKeys, + 'supported_contexts' => $this->supportedContexts, + 'primary' => $this->primary, + 'notes' => $this->notes, + ]; + } + + private static function optionalString(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + $trimmed = trim($value); + + return $trimmed === '' ? null : $trimmed; + } + + /** + * @param iterable $values + * @return list + */ + private static function stringList(iterable $values): array + { + return collect($values) + ->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '') + ->map(static fn (string $value): string => trim($value)) + ->values() + ->all(); + } +} diff --git a/apps/platform/config/canonical_controls.php b/apps/platform/config/canonical_controls.php new file mode 100644 index 00000000..2078fbbb --- /dev/null +++ b/apps/platform/config/canonical_controls.php @@ -0,0 +1,304 @@ + [ + [ + 'control_key' => 'strong_authentication', + 'name' => 'Strong authentication', + 'domain_key' => 'identity_access', + 'subdomain_key' => 'authentication_assurance', + 'control_class' => 'preventive', + 'summary' => 'Accounts and privileged actions require strong authentication before access is granted.', + 'operator_description' => 'Use this control when the governance objective is proving that access depends on multi-factor or similarly strong authentication.', + 'detectability_class' => 'indirect_technical', + 'evaluation_strategy' => 'signal_inferred', + 'evidence_archetypes' => [ + 'configuration_snapshot', + 'policy_or_assignment_summary', + 'execution_result', + ], + 'artifact_suitability' => [ + 'baseline' => true, + 'drift' => true, + 'finding' => true, + 'exception' => true, + 'evidence' => true, + 'review' => true, + 'report' => true, + ], + 'historical_status' => 'active', + 'microsoft_bindings' => [ + [ + 'subject_family_key' => 'conditional_access_policy', + 'workload' => 'entra', + 'signal_keys' => [ + 'conditional_access.require_mfa', + 'conditional_access.authentication_strength', + ], + 'supported_contexts' => ['baseline', 'drift', 'finding', 'evidence', 'review', 'report'], + 'primary' => true, + 'notes' => 'Microsoft conditional access is provider-owned evidence for strong authentication, not the canonical control identity.', + ], + [ + 'subject_family_key' => 'permission_posture', + 'workload' => 'entra', + 'signal_keys' => [ + 'permission_posture.required_graph_permission', + ], + 'supported_contexts' => ['finding', 'evidence', 'review', 'report'], + 'primary' => false, + 'notes' => 'Permission posture can support authentication governance when missing permissions block assessment evidence.', + ], + ], + ], + [ + 'control_key' => 'conditional_access_enforcement', + 'name' => 'Conditional access enforcement', + 'domain_key' => 'identity_access', + 'subdomain_key' => 'access_policy', + 'control_class' => 'preventive', + 'summary' => 'Access decisions are governed by explicit policy conditions and assignment boundaries.', + 'operator_description' => 'Use this control when evaluating whether access is constrained by conditional policies rather than unmanaged default access.', + 'detectability_class' => 'direct_technical', + 'evaluation_strategy' => 'state_evaluated', + 'evidence_archetypes' => [ + 'configuration_snapshot', + 'policy_or_assignment_summary', + ], + 'artifact_suitability' => [ + 'baseline' => true, + 'drift' => true, + 'finding' => true, + 'exception' => true, + 'evidence' => true, + 'review' => true, + 'report' => true, + ], + 'historical_status' => 'active', + 'microsoft_bindings' => [ + [ + 'subject_family_key' => 'conditional_access_policy', + 'workload' => 'entra', + 'signal_keys' => [ + 'conditional_access.policy_state', + 'conditional_access.assignment_scope', + ], + 'supported_contexts' => ['baseline', 'drift', 'finding', 'evidence', 'review', 'report'], + 'primary' => true, + 'notes' => 'Policy state and assignments are Microsoft-owned signals for the provider-neutral access enforcement objective.', + ], + ], + ], + [ + 'control_key' => 'privileged_access_governance', + 'name' => 'Privileged access governance', + 'domain_key' => 'identity_access', + 'subdomain_key' => 'privileged_access', + 'control_class' => 'preventive', + 'summary' => 'Privileged roles are assigned intentionally, reviewed, and limited to accountable identities.', + 'operator_description' => 'Use this control when privileged role exposure, ownership, and reviewability are the core governance objective.', + 'detectability_class' => 'indirect_technical', + 'evaluation_strategy' => 'signal_inferred', + 'evidence_archetypes' => [ + 'policy_or_assignment_summary', + 'execution_result', + 'operator_attestation', + ], + 'artifact_suitability' => [ + 'baseline' => false, + 'drift' => false, + 'finding' => true, + 'exception' => true, + 'evidence' => true, + 'review' => true, + 'report' => true, + ], + 'historical_status' => 'active', + 'microsoft_bindings' => [ + [ + 'subject_family_key' => 'entra_admin_roles', + 'workload' => 'entra', + 'signal_keys' => [ + 'entra_admin_roles.global_admin_assignment', + 'entra_admin_roles.privileged_role_assignment', + ], + 'supported_contexts' => ['finding', 'evidence', 'review', 'report'], + 'primary' => true, + 'notes' => 'Directory role assignment data supports privileged access governance without becoming the control taxonomy.', + ], + ], + ], + [ + 'control_key' => 'external_sharing_boundaries', + 'name' => 'External sharing boundaries', + 'domain_key' => 'collaboration_boundary', + 'subdomain_key' => 'external_access', + 'control_class' => 'preventive', + 'summary' => 'External access and sharing are constrained by explicit tenant or workload boundaries.', + 'operator_description' => 'Use this control when the product needs to explain whether cross-boundary collaboration is intentionally limited.', + 'detectability_class' => 'workflow_attested', + 'evaluation_strategy' => 'workflow_confirmed', + 'evidence_archetypes' => [ + 'configuration_snapshot', + 'operator_attestation', + 'external_artifact_reference', + ], + 'artifact_suitability' => [ + 'baseline' => false, + 'drift' => false, + 'finding' => false, + 'exception' => true, + 'evidence' => true, + 'review' => true, + 'report' => true, + ], + 'historical_status' => 'active', + 'microsoft_bindings' => [ + [ + 'subject_family_key' => 'sharing_boundary', + 'workload' => 'microsoft_365', + 'signal_keys' => [ + 'sharing.external_boundary_attested', + ], + 'supported_contexts' => ['evidence', 'review', 'report'], + 'primary' => true, + 'notes' => 'Current release coverage depends on attested configuration evidence rather than direct universal evaluation.', + ], + ], + ], + [ + 'control_key' => 'endpoint_hardening_compliance', + 'name' => 'Endpoint hardening and compliance', + 'domain_key' => 'endpoint_security', + 'subdomain_key' => 'device_posture', + 'control_class' => 'detective', + 'summary' => 'Endpoint configuration and compliance policies express the expected device hardening posture.', + 'operator_description' => 'Use this control when a finding or review references device configuration, compliance, or hardening drift.', + 'detectability_class' => 'direct_technical', + 'evaluation_strategy' => 'state_evaluated', + 'evidence_archetypes' => [ + 'configuration_snapshot', + 'policy_or_assignment_summary', + 'execution_result', + ], + 'artifact_suitability' => [ + 'baseline' => true, + 'drift' => true, + 'finding' => true, + 'exception' => true, + 'evidence' => true, + 'review' => true, + 'report' => true, + ], + 'historical_status' => 'active', + 'microsoft_bindings' => [ + [ + 'subject_family_key' => 'deviceConfiguration', + 'workload' => 'intune', + 'signal_keys' => [ + 'intune.device_configuration_drift', + ], + 'supported_contexts' => ['baseline', 'drift', 'finding', 'evidence', 'review', 'report'], + 'primary' => true, + 'notes' => 'Intune device configuration drift is a provider signal for the endpoint hardening control.', + ], + [ + 'subject_family_key' => 'deviceCompliancePolicy', + 'workload' => 'intune', + 'signal_keys' => [ + 'intune.device_compliance_policy', + ], + 'supported_contexts' => ['baseline', 'drift', 'finding', 'evidence', 'review', 'report'], + 'primary' => true, + 'notes' => 'Device compliance policy data supports the same endpoint hardening objective.', + ], + [ + 'subject_family_key' => 'drift', + 'workload' => 'intune', + 'signal_keys' => [ + 'finding.drift', + ], + 'supported_contexts' => ['finding', 'evidence', 'review', 'report'], + 'primary' => true, + 'notes' => 'Legacy drift findings without a policy-family discriminator resolve to the broad endpoint hardening objective.', + ], + ], + ], + [ + 'control_key' => 'audit_log_retention', + 'name' => 'Audit log retention', + 'domain_key' => 'auditability', + 'subdomain_key' => 'retention', + 'control_class' => 'detective', + 'summary' => 'Administrative and security-relevant activity remains available for investigation for the required retention period.', + 'operator_description' => 'Use this control when evidence depends on retained logs or exported audit artifacts rather than live configuration alone.', + 'detectability_class' => 'external_evidence_only', + 'evaluation_strategy' => 'externally_attested', + 'evidence_archetypes' => [ + 'external_artifact_reference', + 'operator_attestation', + ], + 'artifact_suitability' => [ + 'baseline' => false, + 'drift' => false, + 'finding' => false, + 'exception' => true, + 'evidence' => true, + 'review' => true, + 'report' => true, + ], + 'historical_status' => 'active', + 'microsoft_bindings' => [ + [ + 'subject_family_key' => 'audit_log_retention', + 'workload' => 'microsoft_365', + 'signal_keys' => [ + 'audit.retention_attested', + ], + 'supported_contexts' => ['evidence', 'review', 'report'], + 'primary' => true, + 'notes' => 'Current evidence is external or attested until a later slice adds direct provider evaluation.', + ], + ], + ], + [ + 'control_key' => 'delegated_admin_boundaries', + 'name' => 'Delegated admin boundaries', + 'domain_key' => 'identity_access', + 'subdomain_key' => 'delegated_administration', + 'control_class' => 'preventive', + 'summary' => 'Delegated administration is constrained by explicit role, tenant, and scope boundaries.', + 'operator_description' => 'Use this control when evaluating whether delegated administrative access is bounded and reviewable.', + 'detectability_class' => 'workflow_attested', + 'evaluation_strategy' => 'workflow_confirmed', + 'evidence_archetypes' => [ + 'policy_or_assignment_summary', + 'operator_attestation', + ], + 'artifact_suitability' => [ + 'baseline' => false, + 'drift' => false, + 'finding' => true, + 'exception' => true, + 'evidence' => true, + 'review' => true, + 'report' => true, + ], + 'historical_status' => 'active', + 'microsoft_bindings' => [ + [ + 'subject_family_key' => 'delegated_admin_relationship', + 'workload' => 'microsoft_365', + 'signal_keys' => [ + 'delegated_admin.relationship_boundary', + ], + 'supported_contexts' => ['finding', 'evidence', 'review', 'report'], + 'primary' => true, + 'notes' => 'Delegated admin relationship metadata remains provider-owned and secondary to the platform control.', + ], + ], + ], + ], +]; diff --git a/apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php b/apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php new file mode 100644 index 00000000..7c5af726 --- /dev/null +++ b/apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php @@ -0,0 +1,67 @@ +permissionPosture()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + ]); + Finding::factory()->entraAdminRoles()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + ]); + Finding::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'evidence_jsonb' => [ + 'policy_type' => 'deviceConfiguration', + ], + ]); + + $item = app(FindingsSummarySource::class)->collect($tenant); + $summary = $item['summary_payload']; + + expect($summary['canonical_controls'])->toHaveCount(3) + ->and(collect($summary['canonical_controls'])->pluck('control_key')->all())->toEqualCanonicalizing([ + 'endpoint_hardening_compliance', + 'privileged_access_governance', + 'strong_authentication', + ]); + + foreach ($summary['entries'] as $entry) { + expect($entry['canonical_control_resolution']['status'])->toBe('resolved') + ->and($entry['canonical_control_resolution']['control'])->toHaveKey('control_key') + ->and($entry)->not->toHaveKey('control_label'); + } + + $payload = app(EvidenceSnapshotService::class)->buildSnapshotPayload($tenant); + + expect($payload['summary']['canonical_controls'])->toHaveCount(3); +}); + +it('keeps missing bindings explicit instead of inventing evidence fallback labels', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + Finding::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'finding_type' => 'unknown_provider_signal', + ]); + + $summary = app(FindingsSummarySource::class)->collect($tenant)['summary_payload']; + $entry = $summary['entries'][0]; + + expect($entry['canonical_control_resolution'])->toMatchArray([ + 'status' => 'unresolved', + 'reason_code' => 'missing_binding', + ])->and($entry['canonical_control_resolution'])->not->toHaveKey('control') + ->and($entry)->not->toHaveKey('control_label'); +}); diff --git a/apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php b/apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php new file mode 100644 index 00000000..f013d6c7 --- /dev/null +++ b/apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php @@ -0,0 +1,75 @@ + app(CanonicalControlCatalog::class)->listPayload(), + ]; + + expect($payload['controls'])->not->toBeEmpty(); + + foreach ($payload['controls'] as $control) { + expect($control)->toHaveKeys([ + 'control_key', + 'name', + 'domain_key', + 'subdomain_key', + 'control_class', + 'summary', + 'operator_description', + 'detectability_class', + 'evaluation_strategy', + 'evidence_archetypes', + 'artifact_suitability', + 'historical_status', + ])->and($control['artifact_suitability'])->toHaveKeys([ + 'baseline', + 'drift', + 'finding', + 'exception', + 'evidence', + 'review', + 'report', + ]); + } +}); + +it('returns resolved, unresolved, and ambiguous resolution shapes without guessing', function (): void { + $resolver = app(CanonicalControlResolver::class); + + $resolved = $resolver->resolve(new CanonicalControlResolutionRequest( + provider: 'microsoft', + consumerContext: 'review', + subjectFamilyKey: 'entra_admin_roles', + workload: 'entra', + signalKey: 'entra_admin_roles.global_admin_assignment', + ))->toArray(); + $unresolved = $resolver->resolve(new CanonicalControlResolutionRequest( + provider: 'microsoft', + consumerContext: 'review', + subjectFamilyKey: 'not_bound', + ))->toArray(); + $ambiguous = $resolver->resolve(new CanonicalControlResolutionRequest( + provider: 'microsoft', + consumerContext: 'review', + subjectFamilyKey: 'conditional_access_policy', + workload: 'entra', + ))->toArray(); + + expect($resolved)->toHaveKeys(['status', 'control']) + ->and($resolved['status'])->toBe('resolved') + ->and($resolved['control']['control_key'])->toBe('privileged_access_governance') + ->and($unresolved)->toHaveKeys(['status', 'reason_code', 'binding_context']) + ->and($unresolved['reason_code'])->toBe('missing_binding') + ->and($ambiguous)->toHaveKeys(['status', 'reason_code', 'candidate_control_keys', 'binding_context']) + ->and($ambiguous['status'])->toBe('ambiguous') + ->and($ambiguous['candidate_control_keys'])->toEqualCanonicalizing([ + 'conditional_access_enforcement', + 'strong_authentication', + ]); +}); diff --git a/apps/platform/tests/Feature/PlatformRelocation/CommandModelSmokeTest.php b/apps/platform/tests/Feature/PlatformRelocation/CommandModelSmokeTest.php index 879fafee..e39b2941 100644 --- a/apps/platform/tests/Feature/PlatformRelocation/CommandModelSmokeTest.php +++ b/apps/platform/tests/Feature/PlatformRelocation/CommandModelSmokeTest.php @@ -15,7 +15,8 @@ ->and(is_executable($scriptPath))->toBeTrue() ->and($scriptContents)->toContain('APP_DIR') ->toContain('apps/platform') - ->toContain('exec ./vendor/bin/sail "$@"'); + ->toContain('exec ./vendor/bin/sail "$@"') + ->not->toContain('COMPOSE_PROJECT_NAME'); }); it('keeps the repo root compose file pointed at the relocated app', function (): void { diff --git a/apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php b/apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php new file mode 100644 index 00000000..a2eab817 --- /dev/null +++ b/apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php @@ -0,0 +1,18 @@ +sections->firstWhere('section_key', 'open_risks'); + $executiveSummary = $review->sections->firstWhere('section_key', 'executive_summary'); + + expect($review->canonicalControlReferences())->toHaveCount(1) + ->and($review->canonicalControlReferences()[0]['control_key'])->toBe('endpoint_hardening_compliance') + ->and($executiveSummary->summary_payload['canonical_control_count'])->toBe(1) + ->and($executiveSummary->summary_payload['canonical_controls'][0]['control_key'])->toBe('endpoint_hardening_compliance') + ->and($openRisks->summary_payload['canonical_controls'])->toBe([]); +}); diff --git a/apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php b/apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php new file mode 100644 index 00000000..ab0d71d6 --- /dev/null +++ b/apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php @@ -0,0 +1,74 @@ +all())->toHaveCount(7); + + foreach ($catalog->all() as $definition) { + expect($definition->controlKey)->toMatch('/^[a-z][a-z0-9_]*$/') + ->and($definition->name)->not->toBeEmpty() + ->and($definition->domainKey)->not->toContain('microsoft') + ->and($definition->domainKey)->not->toContain('intune') + ->and($definition->subdomainKey)->not->toBeEmpty() + ->and($definition->controlClass)->not->toBeEmpty() + ->and($definition->summary)->not->toBeEmpty() + ->and($definition->operatorDescription)->not->toBeEmpty() + ->and($definition->detectabilityClass)->toBeInstanceOf(DetectabilityClass::class) + ->and($definition->evaluationStrategy)->toBeInstanceOf(EvaluationStrategy::class) + ->and($definition->evidenceArchetypes)->not->toBeEmpty() + ->and(array_keys($definition->artifactSuitability->toArray()))->toBe([ + 'baseline', + 'drift', + 'finding', + 'exception', + 'evidence', + 'review', + 'report', + ]) + ->and($definition->historicalStatus)->toBeIn(['active', 'retired']); + } +}); + +it('seeds the first-slice high-value control families', function (): void { + $keys = array_map( + static fn ($definition): string => $definition->controlKey, + app(CanonicalControlCatalog::class)->all(), + ); + + expect($keys)->toEqualCanonicalizing([ + 'audit_log_retention', + 'conditional_access_enforcement', + 'delegated_admin_boundaries', + 'endpoint_hardening_compliance', + 'external_sharing_boundaries', + 'privileged_access_governance', + 'strong_authentication', + ]); +}); + +it('keeps Microsoft bindings secondary to the definition payload', function (): void { + $catalog = app(CanonicalControlCatalog::class); + $definition = $catalog->find('endpoint_hardening_compliance'); + + expect($definition?->toArray())->not->toHaveKey('microsoft_bindings') + ->and($catalog->microsoftBindingsForControl('endpoint_hardening_compliance'))->not->toBeEmpty() + ->and($catalog->microsoftBindingsForControl('endpoint_hardening_compliance')[0]->toArray()['provider'])->toBe('microsoft'); +}); + +it('preserves honest detectability, evaluation, and suitability distinctions', function (): void { + $catalog = app(CanonicalControlCatalog::class); + + expect($catalog->find('endpoint_hardening_compliance')?->detectabilityClass)->toBe(DetectabilityClass::DirectTechnical) + ->and($catalog->find('endpoint_hardening_compliance')?->evaluationStrategy)->toBe(EvaluationStrategy::StateEvaluated) + ->and($catalog->find('audit_log_retention')?->detectabilityClass)->toBe(DetectabilityClass::ExternalEvidenceOnly) + ->and($catalog->find('audit_log_retention')?->evaluationStrategy)->toBe(EvaluationStrategy::ExternallyAttested) + ->and($catalog->find('audit_log_retention')?->artifactSuitability->baseline)->toBeFalse() + ->and($catalog->find('audit_log_retention')?->artifactSuitability->review)->toBeTrue(); +}); diff --git a/apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php b/apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php new file mode 100644 index 00000000..3cd63179 --- /dev/null +++ b/apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php @@ -0,0 +1,177 @@ +resolve(new CanonicalControlResolutionRequest( + provider: 'microsoft', + consumerContext: 'evidence', + subjectFamilyKey: 'deviceConfiguration', + workload: 'intune', + signalKey: 'intune.device_configuration_drift', + )); + $complianceResult = $resolver->resolve(new CanonicalControlResolutionRequest( + provider: 'microsoft', + consumerContext: 'review', + subjectFamilyKey: 'deviceCompliancePolicy', + workload: 'intune', + signalKey: 'intune.device_compliance_policy', + )); + + expect($configurationResult->toArray()['control']['control_key'])->toBe('endpoint_hardening_compliance') + ->and($complianceResult->toArray()['control']['control_key'])->toBe('endpoint_hardening_compliance') + ->and($configurationResult->toArray()['control']['name'])->toBe($complianceResult->toArray()['control']['name']); +}); + +it('uses supplied signal context instead of letting workload labels become primary identity', function (): void { + $resolver = app(CanonicalControlResolver::class); + + $strongAuthentication = $resolver->resolve(new CanonicalControlResolutionRequest( + provider: 'microsoft', + consumerContext: 'evidence', + subjectFamilyKey: 'conditional_access_policy', + workload: 'entra', + signalKey: 'conditional_access.require_mfa', + )); + $accessEnforcement = $resolver->resolve(new CanonicalControlResolutionRequest( + provider: 'microsoft', + consumerContext: 'evidence', + subjectFamilyKey: 'conditional_access_policy', + workload: 'entra', + signalKey: 'conditional_access.policy_state', + )); + + expect($strongAuthentication->toArray()['control']['control_key'])->toBe('strong_authentication') + ->and($accessEnforcement->toArray()['control']['control_key'])->toBe('conditional_access_enforcement'); +}); + +it('returns explicit unresolved reason codes instead of fallback labels', function (): void { + $resolver = app(CanonicalControlResolver::class); + + expect($resolver->resolve(new CanonicalControlResolutionRequest( + provider: 'unknown', + consumerContext: 'evidence', + subjectFamilyKey: 'deviceConfiguration', + ))->toArray())->toMatchArray([ + 'status' => 'unresolved', + 'reason_code' => 'unsupported_provider', + ]); + + expect($resolver->resolve(new CanonicalControlResolutionRequest( + provider: 'microsoft', + consumerContext: 'evidence', + ))->toArray())->toMatchArray([ + 'status' => 'unresolved', + 'reason_code' => 'insufficient_context', + ]); + + expect($resolver->resolve(new CanonicalControlResolutionRequest( + provider: 'microsoft', + consumerContext: 'evidence', + subjectFamilyKey: 'not_bound', + ))->toArray())->toMatchArray([ + 'status' => 'unresolved', + 'reason_code' => 'missing_binding', + ]); +}); + +it('fails deterministically when a binding context is ambiguous', function (): void { + $resolver = new CanonicalControlResolver(new CanonicalControlCatalog([ + spec236ControlDefinition('first_control', [ + 'microsoft_bindings' => [ + spec236Binding('shared_subject', primary: false), + ], + ]), + spec236ControlDefinition('second_control', [ + 'microsoft_bindings' => [ + spec236Binding('shared_subject', primary: false), + ], + ]), + ])); + + $result = $resolver->resolve(new CanonicalControlResolutionRequest( + provider: 'microsoft', + consumerContext: 'evidence', + subjectFamilyKey: 'shared_subject', + workload: 'entra', + signalKey: 'shared.signal', + ))->toArray(); + + expect($result['status'])->toBe('ambiguous') + ->and($result['reason_code'])->toBe('ambiguous_binding') + ->and($result['candidate_control_keys'])->toBe(['first_control', 'second_control']); +}); + +it('keeps retired controls resolvable for historical references', function (): void { + $resolver = new CanonicalControlResolver(new CanonicalControlCatalog([ + spec236ControlDefinition('retired_control', [ + 'historical_status' => 'retired', + 'microsoft_bindings' => [ + spec236Binding('retired_subject'), + ], + ]), + ])); + + $result = $resolver->resolve(new CanonicalControlResolutionRequest( + provider: 'microsoft', + consumerContext: 'review', + subjectFamilyKey: 'retired_subject', + workload: 'entra', + signalKey: 'shared.signal', + ))->toArray(); + + expect($result['status'])->toBe('resolved') + ->and($result['control']['control_key'])->toBe('retired_control') + ->and($result['control']['historical_status'])->toBe('retired'); +}); + +/** + * @param array $overrides + * @return array + */ +function spec236ControlDefinition(string $controlKey, array $overrides = []): array +{ + return array_replace_recursive([ + 'control_key' => $controlKey, + 'name' => str_replace('_', ' ', ucfirst($controlKey)), + 'domain_key' => 'identity_access', + 'subdomain_key' => 'test_subjects', + 'control_class' => 'preventive', + 'summary' => 'Test summary.', + 'operator_description' => 'Test operator description.', + 'detectability_class' => 'direct_technical', + 'evaluation_strategy' => 'state_evaluated', + 'evidence_archetypes' => ['configuration_snapshot'], + 'artifact_suitability' => [ + 'baseline' => true, + 'drift' => true, + 'finding' => true, + 'exception' => true, + 'evidence' => true, + 'review' => true, + 'report' => true, + ], + 'historical_status' => 'active', + 'microsoft_bindings' => [], + ], $overrides); +} + +/** + * @return array + */ +function spec236Binding(string $subjectFamilyKey, bool $primary = true): array +{ + return [ + 'subject_family_key' => $subjectFamilyKey, + 'workload' => 'entra', + 'signal_keys' => ['shared.signal'], + 'supported_contexts' => ['evidence', 'review'], + 'primary' => $primary, + ]; +} diff --git a/scripts/platform-sail b/scripts/platform-sail index df4a768d..980e178d 100755 --- a/scripts/platform-sail +++ b/scripts/platform-sail @@ -11,6 +11,4 @@ APP_DIR="${SCRIPT_DIR}/../apps/platform" cd "${APP_DIR}" -export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-tenantatlas}" - exec ./vendor/bin/sail "$@" diff --git a/specs/236-canonical-control-catalog-foundation/checklists/requirements.md b/specs/236-canonical-control-catalog-foundation/checklists/requirements.md new file mode 100644 index 00000000..e00e5bfd --- /dev/null +++ b/specs/236-canonical-control-catalog-foundation/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Canonical Control Catalog Foundation + +**Purpose**: Capture specification completeness and quality at planning handoff while keeping post-plan status aligned with the current artifact set +**Created**: 2026-04-24 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation algorithms, code diffs, or migration steps +- [x] Focused on user value and business needs +- [x] Repo-specific constitutional and provider-boundary references remain intentional and bounded +- [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 +- [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 algorithms or file-by-file execution steps leak into specification + +## Notes + +- This checklist records readiness at planning handoff; `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/`, and `tasks.md` are the implementation-facing artifacts for this feature. +- The first slice remains product-seeded, persistence-neutral, and bounded to shared control resolution plus downstream evidence and tenant review adoption. +- No clarification markers remain, and the current scope is aligned across spec, plan, tasks, and supporting artifacts for implementation. \ No newline at end of file diff --git a/specs/236-canonical-control-catalog-foundation/contracts/canonical-control-catalog.logical.openapi.yaml b/specs/236-canonical-control-catalog-foundation/contracts/canonical-control-catalog.logical.openapi.yaml new file mode 100644 index 00000000..efd16c09 --- /dev/null +++ b/specs/236-canonical-control-catalog-foundation/contracts/canonical-control-catalog.logical.openapi.yaml @@ -0,0 +1,252 @@ +openapi: 3.1.0 +info: + title: Canonical Control Catalog Logical Contract + version: 0.1.0 + description: | + Logical contract for the first canonical control catalog slice. + This describes shared internal request and response shapes for catalog lookup + and control resolution. It is not a commitment to expose public HTTP routes. +paths: + /logical/canonical-controls/catalog: + get: + summary: List the seeded canonical control definitions + operationId: listCanonicalControls + responses: + '200': + description: Seeded canonical controls + content: + application/json: + schema: + type: object + properties: + controls: + type: array + items: + $ref: '#/components/schemas/CanonicalControlDefinition' + required: + - controls + /logical/canonical-controls/resolve: + post: + summary: Resolve canonical control metadata for a governed subject or signal context + operationId: resolveCanonicalControl + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ControlResolutionRequest' + responses: + '200': + description: Control resolution outcome + content: + application/json: + schema: + $ref: '#/components/schemas/ControlResolutionResponse' +components: + schemas: + CanonicalControlDefinition: + type: object + properties: + control_key: + type: string + name: + type: string + domain_key: + type: string + subdomain_key: + type: string + control_class: + type: string + summary: + type: string + operator_description: + type: string + detectability_class: + type: string + enum: + - direct_technical + - indirect_technical + - workflow_attested + - external_evidence_only + evaluation_strategy: + type: string + enum: + - state_evaluated + - signal_inferred + - workflow_confirmed + - externally_attested + evidence_archetypes: + type: array + items: + $ref: '#/components/schemas/EvidenceArchetype' + artifact_suitability: + type: object + properties: + baseline: + type: boolean + drift: + type: boolean + finding: + type: boolean + exception: + type: boolean + evidence: + type: boolean + review: + type: boolean + report: + type: boolean + required: + - baseline + - drift + - finding + - exception + - evidence + - review + - report + historical_status: + type: string + enum: + - active + - retired + required: + - control_key + - name + - domain_key + - subdomain_key + - control_class + - summary + - operator_description + - detectability_class + - evaluation_strategy + - evidence_archetypes + - artifact_suitability + - historical_status + ControlResolutionRequest: + type: object + description: All supplied discriminator fields combine conjunctively. The resolver narrows bindings by provider, consumer context, and every provided subject-family, workload, or signal value. + properties: + provider: + type: string + enum: + - microsoft + subject_family_key: + type: string + workload: + type: string + signal_key: + type: string + consumer_context: + type: string + enum: + - baseline + - drift + - finding + - evidence + - exception + - review + - report + anyOf: + - required: + - subject_family_key + - required: + - workload + - required: + - signal_key + required: + - provider + - consumer_context + BindingContext: + type: object + properties: + provider: + type: string + enum: + - microsoft + subject_family_key: + type: string + workload: + type: string + signal_key: + type: string + consumer_context: + type: string + enum: + - baseline + - drift + - finding + - evidence + - exception + - review + - report + EvidenceArchetype: + type: string + enum: + - configuration_snapshot + - execution_result + - policy_or_assignment_summary + - operator_attestation + - external_artifact_reference + UnresolvedControlResolutionReasonCode: + type: string + enum: + - missing_binding + - unsupported_provider + - unsupported_consumer_context + - insufficient_context + AmbiguousControlResolutionReasonCode: + type: string + enum: + - ambiguous_binding + ResolvedControlResolutionResponse: + type: object + properties: + status: + type: string + enum: + - resolved + control: + $ref: '#/components/schemas/CanonicalControlDefinition' + required: + - status + - control + UnresolvedControlResolutionResponse: + type: object + properties: + status: + type: string + enum: + - unresolved + reason_code: + $ref: '#/components/schemas/UnresolvedControlResolutionReasonCode' + binding_context: + $ref: '#/components/schemas/BindingContext' + required: + - status + - reason_code + - binding_context + AmbiguousControlResolutionResponse: + type: object + properties: + status: + type: string + enum: + - ambiguous + reason_code: + $ref: '#/components/schemas/AmbiguousControlResolutionReasonCode' + candidate_control_keys: + type: array + items: + type: string + binding_context: + $ref: '#/components/schemas/BindingContext' + required: + - status + - reason_code + - candidate_control_keys + - binding_context + ControlResolutionResponse: + oneOf: + - $ref: '#/components/schemas/ResolvedControlResolutionResponse' + - $ref: '#/components/schemas/UnresolvedControlResolutionResponse' + - $ref: '#/components/schemas/AmbiguousControlResolutionResponse' \ No newline at end of file diff --git a/specs/236-canonical-control-catalog-foundation/data-model.md b/specs/236-canonical-control-catalog-foundation/data-model.md new file mode 100644 index 00000000..89eafbae --- /dev/null +++ b/specs/236-canonical-control-catalog-foundation/data-model.md @@ -0,0 +1,133 @@ +# Data Model: Canonical Control Catalog Foundation + +## Overview + +The first slice introduces a product-seeded control catalog and a shared resolution contract. The catalog itself is not operator-managed persistence in v1; it is a bounded canonical registry consumed by existing governance domains. + +## Entity: CanonicalControlDefinition + +- **Purpose**: Represents one stable governance control objective independent of framework clauses, provider identifiers, or individual workload payloads. +- **Identity**: + - `control_key` — stable canonical slug, unique across the catalog +- **Core fields**: + - `name` + - `domain_key` + - `subdomain_key` + - `control_class` + - `summary` + - `operator_description` +- **Semantics fields**: + - `detectability_class` + - `evaluation_strategy` + - `evidence_archetypes[]` + - `artifact_suitability` + - `historical_status` — `active` or `retired` +- **Validation rules**: + - `control_key` must be stable, lowercase, and provider-neutral. + - `domain_key` and `subdomain_key` must point to canonical catalog taxonomy, not framework or provider namespaces. + - Each control must declare at least one evidence archetype. + - Each control must declare explicit suitability flags for baseline, drift, finding, exception, evidence, review, and report usage. + +## Entity: MicrosoftSubjectBinding + +- **Purpose**: Connects provider-owned Microsoft subjects, workloads, or signals to one canonical control without redefining the control. +- **Fields**: + - `control_key` + - `provider` — always `microsoft` in the first slice + - `subject_family_key` + - `workload` + - `signal_keys[]` + - `supported_contexts[]` — for example baseline, finding, evidence, exception, review, report + - `primary` — whether this binding is the default control for the declared context + - `notes` +- **Validation rules**: + - Every binding must reference an existing `control_key`. + - Provider-specific descriptors must not overwrite control-core terminology. + - More than one binding may point to the same control. + - Multiple controls may only claim the same binding context when the ambiguity is intentionally declared and handled. + +## Entity: CanonicalControlResolutionResult + +- **Purpose**: Shared response contract for downstream consumers. +- **Resolver matching rule**: provider, consumer context, and every supplied subject-family, workload, or signal discriminator combine conjunctively to narrow candidate bindings. +- **States**: + - `resolved` + - `unresolved` + - `ambiguous` +- **Fields when resolved**: + - `status` — always `resolved` + - `control` — full `CanonicalControlDefinition` payload containing: + - `control_key` + - `name` + - `domain_key` + - `subdomain_key` + - `control_class` + - `summary` + - `operator_description` + - `detectability_class` + - `evaluation_strategy` + - `evidence_archetypes[]` + - `artifact_suitability` + - `historical_status` +- **Fields when unresolved**: + - `reason_code` — stable failure vocabulary such as `missing_binding`, `unsupported_provider`, `unsupported_consumer_context`, or `insufficient_context` + - `binding_context` +- **Fields when ambiguous**: + - `reason_code` — stable failure vocabulary, including `ambiguous_binding` + - `candidate_control_keys[]` + - `binding_context` +- **Validation rules**: + - `resolved` returns exactly one canonical control. + - All supplied discriminator inputs must narrow resolution together; the resolver must not ignore a provided field to force a match. + - `ambiguous` returns no guessed winner. + - `unresolved` returns no local fallback label. + +## Supporting Classifications + +### DetectabilityClass + +- `direct_technical` +- `indirect_technical` +- `workflow_attested` +- `external_evidence_only` + +### EvaluationStrategy + +- `state_evaluated` +- `signal_inferred` +- `workflow_confirmed` +- `externally_attested` + +### EvidenceArchetype + +- `configuration_snapshot` +- `execution_result` +- `policy_or_assignment_summary` +- `operator_attestation` +- `external_artifact_reference` + +## Relationships + +- One `CanonicalControlDefinition` has many `MicrosoftSubjectBinding` records. +- One `MicrosoftSubjectBinding` references exactly one canonical control. +- One governed subject or signal context may resolve to one control or to an explicit ambiguous set. +- Existing governance consumers remain the owners of their own records and read models; they do not become child entities of the canonical catalog. + +## Lifecycle + +### CanonicalControlDefinition lifecycle + +- `active`: valid for new bindings and downstream use +- `retired`: historical references remain resolvable, but new adoption should stop unless explicitly allowed + +### Resolution lifecycle + +- `resolved`: downstream consumer may use canonical metadata directly +- `unresolved`: downstream consumer must surface or log explicit absence rather than invent local meaning +- `ambiguous`: downstream consumer must stop and preserve explicit ambiguity until the binding model is clarified + +## Rollout Model + +- The first slice keeps the catalog seeded in code and consumed through the resolver. +- Broad persistence of `canonical_control_key` on downstream entities is deferred. +- First-slice adoption is read-through and bounded to findings-derived evidence composition and tenant review composition. \ No newline at end of file diff --git a/specs/236-canonical-control-catalog-foundation/plan.md b/specs/236-canonical-control-catalog-foundation/plan.md new file mode 100644 index 00000000..b6a5eb60 --- /dev/null +++ b/specs/236-canonical-control-catalog-foundation/plan.md @@ -0,0 +1,250 @@ +# Implementation Plan: Canonical Control Catalog Foundation + +**Branch**: `236-canonical-control-catalog-foundation` | **Date**: 2026-04-24 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/236-canonical-control-catalog-foundation/spec.md` + +**Note**: This plan keeps the slice intentionally narrow. It introduces one product-seeded canonical control catalog plus one shared resolver contract, then adopts that contract only in findings-derived evidence composition and tenant review composition without adding operator CRUD, Graph sync, or a new operator-facing surface. + +## Summary + +Add a config-seeded canonical control catalog in `apps/platform/config/canonical_controls.php` plus a small `App\Support\Governance\Controls` value-object and resolver layer so the same governance objective resolves to one stable control identity, one honest detectability story, and one provider-neutral vocabulary. The implementation will keep Microsoft workload, subject-family, and signal mappings as secondary provider-owned bindings, expose explicit `resolved`, `unresolved`, and `ambiguous` outcomes, and adopt the shared contract only in the existing findings-derived evidence composition and tenant review composition paths instead of widening into baseline, exception, report, or review-pack consumers in this slice. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 +**Primary Dependencies**: existing governance support types under `App\Support\Governance`, `EvidenceSnapshotResolver`, `EvidenceSnapshotService`, `FindingsSummarySource`, `TenantReviewComposer`, `TenantReviewSectionFactory`, `TenantReviewService`, Pest v4 +**Storage**: Existing PostgreSQL tables for downstream evidence and tenant review records; product-seeded in-repo config for canonical control definitions and Microsoft bindings +**Testing**: Pest v4 unit and feature tests through Laravel Sail +**Validation Lanes**: `fast-feedback`, `confidence` +**Target Platform**: Laravel admin web application running in Sail with existing `/admin` and `/admin/t/{tenant}` surfaces +**Project Type**: Monorepo with one Laravel runtime in `apps/platform` and spec artifacts at repository root +**Performance Goals**: Keep catalog lookup deterministic and in-process, add no outbound provider calls, and avoid new high-cardinality or repeated per-item resolver work in evidence or tenant review composition +**Constraints**: No new Graph calls, no sync job, no DB-backed control authoring UI, no new operator-facing page, no new persistence table, and no provider-specific vocabulary leaking into platform-core control identity +**Scale/Scope**: One config-backed catalog, one shared resolver, one bounded Microsoft binding family, two first-slice downstream adoption paths, and focused governance foundation unit plus feature tests + +## Filament v5 Implementation Contract + +- **Livewire v4.0+ compliance**: Preserved. This slice changes shared services and value objects only and introduces no legacy Livewire patterns. +- **Provider registration location**: Unchanged. Panel providers remain registered in `apps/platform/bootstrap/providers.php`. +- **Global search coverage**: No new Filament Resource or Page is added, and no existing global-search posture changes in this slice. +- **Destructive actions**: No destructive action is added or changed. This slice does not introduce new Filament actions. +- **Asset strategy**: No new assets are planned. Deployment expectations remain unchanged, including `cd apps/platform && php artisan filament:assets` only when future UI work introduces registered assets. +- **Testing plan**: Prove the slice with focused Pest unit coverage for catalog and resolver rules plus focused feature coverage for logical resolution, findings-derived evidence composition, and tenant review composition. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: no operator-facing surface change +- **Native vs custom classification summary**: `N/A` +- **Shared-family relevance**: evidence viewers, tenant review detail composition, governance summaries +- **State layers in scope**: detail +- **Handling modes by drift class or surface**: `report-only` +- **Repository-signal treatment**: `report-only` +- **Special surface test profiles**: `standard-native-filament` +- **Required tests or manual smoke**: `functional-core` +- **Exception path and spread control**: none planned; any later UI adoption stays in a follow-through slice +- **Active feature PR close-out entry**: `Guardrail` + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: findings-derived evidence composition, evidence snapshot lookup, tenant review composition, tenant review section rendering inputs +- **Shared abstractions reused**: existing evidence composition paths, existing tenant review composition paths, existing governance support types, new shared `CanonicalControlCatalog` and `CanonicalControlResolver` +- **New abstraction introduced? why?**: yes. A bounded catalog plus resolver layer is required because existing builders only know provider subjects or local evidence context and cannot safely share one control identity. +- **Why the existing abstraction was sufficient or insufficient**: existing builders are sufficient for surface-specific formatting, but insufficient for cross-domain control identity, detectability semantics, and provider-neutral vocabulary. +- **Bounded deviation / spread control**: none. Downstream consumers must call the shared resolver rather than add local registries or fallback labels. + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes +- **Provider-owned seams**: Microsoft workload labels, subject-family identifiers, signal keys, supported-context bindings +- **Platform-core seams**: canonical control key, control domain, control subdomain, control class, detectability class, evaluation strategy, evidence archetypes, artifact suitability, historical status +- **Neutral platform terms / contracts preserved**: canonical control, provider binding, governed subject, detectability class, evaluation strategy, evidence archetype, artifact suitability +- **Retained provider-specific semantics and why**: Microsoft binding metadata remains provider-specific because the current product truth is Microsoft-first, but it remains secondary to the canonical control identity. +- **Bounded extraction or follow-up path**: none in this slice; future provider expansion can layer on the same binding model if and when a second concrete provider exists + +## Constitution Check + +*GATE: Passed before Phase 0 research. Re-check after Phase 1 design: still passed with no new persistence, no new operator surface, no Graph path, and no auth-plane drift.* + +| Gate | Status | Plan Notes | +|------|--------|------------| +| Inventory-first / read-write separation | PASS | This slice is read-focused and in-process only. No new write, preview, or operator mutation flow is introduced. | +| RBAC, workspace isolation, tenant isolation | PASS | No new route or capability is added. Evidence and tenant review authorization remain the guarding surfaces for first-slice consumer metadata. | +| Run observability / Ops-UX lifecycle | PASS | No new `OperationRun` type is introduced. Existing evidence and tenant review operation semantics remain unchanged. | +| Shared pattern first | PASS | Evidence and tenant review builders remain the surface-specific composition paths; the shared catalog and resolver provide the missing control identity only. | +| Proportionality / no premature abstraction | PASS | One catalog and one resolver are the narrowest correct shared layer. No DB CRUD, no plugin framework, and no new persistence are introduced. | +| Persisted truth / behavioral state | PASS | No new table, entity, or lifecycle state is introduced. First-slice adoption is read-through only. | +| Provider boundary | PASS | Microsoft semantics remain secondary binding metadata and do not replace the platform-core control vocabulary. | +| Filament v5 / Livewire v4 contract | PASS | No new Filament surfaces or actions are added, and provider registration remains in `bootstrap/providers.php`. | +| Test governance | PASS | Coverage stays in focused unit and feature lanes with no browser or heavy-governance expansion. | + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Unit` for catalog and resolver semantics; `Feature` for findings-derived evidence composition, logical resolution, and tenant review composition +- **Affected validation lanes**: `fast-feedback`, `confidence` +- **Why this lane mix is the narrowest sufficient proof**: The core risk is semantic drift, not browser behavior. Unit tests prove deterministic catalog and binding rules; feature tests prove first-slice consumers use the shared contract instead of local labels. +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php` + - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- **Fixture / helper / factory / seed / context cost risks**: Minimal. Use config-seeded catalog fixtures and existing evidence or tenant review factories only where the downstream consumer proof needs persisted context. +- **Expensive defaults or shared helper growth introduced?**: No. The catalog remains config-backed and in-process by default. +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: `standard-native-filament` relief; the slice does not add or materially refactor a Filament screen +- **Closing validation and reviewer handoff**: Reviewers should verify that no Graph client or sync job changed, that first-slice adoption is limited to findings-derived evidence and tenant review composition, that unresolved and ambiguous outcomes never guess, and that provider-specific labels never replace canonical control vocabulary. +- **Budget / baseline / trend follow-up**: none expected +- **Review-stop questions**: Did any change widen consumer adoption beyond the intended first slice? Did any change introduce Graph or sync behavior? Did any feature-local control registry or fallback label appear? Did any contract field drift from the data model or seeded metadata? +- **Escalation path**: `document-in-feature` +- **Active feature PR close-out entry**: `Guardrail` +- **Why no dedicated follow-up spec is needed**: The slice is a bounded semantic foundation. Only future consumer expansion or a second provider would justify a wider follow-up spec. + +## Project Structure + +### Documentation (this feature) + +```text +specs/236-canonical-control-catalog-foundation/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── canonical-control-catalog.logical.openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Models/ +│ │ ├── EvidenceSnapshot.php +│ │ ├── EvidenceSnapshotItem.php +│ │ └── TenantReview.php +│ ├── Services/ +│ │ ├── Evidence/ +│ │ │ ├── EvidenceSnapshotResolver.php +│ │ │ ├── EvidenceSnapshotService.php +│ │ │ └── Sources/ +│ │ │ └── FindingsSummarySource.php +│ │ └── TenantReviews/ +│ │ ├── TenantReviewComposer.php +│ │ ├── TenantReviewSectionFactory.php +│ │ └── TenantReviewService.php +│ └── Support/ +│ └── Governance/ +│ ├── GovernanceDomainKey.php +│ └── Controls/ +├── config/ +│ └── canonical_controls.php +└── tests/ + ├── Feature/ + │ ├── Evidence/ + │ │ └── EvidenceSnapshotCanonicalControlReferenceTest.php + │ ├── Governance/ + │ │ └── CanonicalControlResolutionIntegrationTest.php + │ └── TenantReview/ + │ └── TenantReviewCanonicalControlReferenceTest.php + └── Unit/ + └── Governance/ + ├── CanonicalControlCatalogTest.php + └── CanonicalControlResolverTest.php +``` + +**Structure Decision**: Keep the slice entirely inside the existing Laravel runtime in `apps/platform`. The new structure is limited to one config-backed seed file and one small `App\Support\Governance\Controls` namespace, while first-slice consumer adoption stays inside existing Evidence and TenantReview services plus focused Pest tests. + +## Complexity Tracking + +No constitutional violation is planned. No complexity exception is currently required. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| — | — | — | + +## Proportionality Review + +- **Current operator problem**: The same governance objective is still described differently across findings-derived evidence and tenant review composition, which prevents stable control identity and honest detectability semantics. +- **Existing structure is insufficient because**: current governed-subject and workload metadata explain provider context, not the higher-order control objective or what kind of proof the product can honestly claim. +- **Narrowest correct implementation**: add one config-backed canonical control catalog plus one shared resolver and adopt it only in findings-derived evidence composition and tenant review composition. +- **Ownership cost created**: maintain the seed catalog, keep binding rules deterministic, and preserve regression tests that block local control-family drift. +- **Alternative intentionally rejected**: feature-local mappings and a DB-backed control authoring system. The first preserves fragmentation; the second imports unnecessary lifecycle, UI, and persistence complexity before the foundation is proven. +- **Release truth**: current-release truth with deliberate preparation for later consumer expansion + +## Phase 0 Research Summary + +- The first catalog should be product-seeded and config-backed, not DB-managed. +- Platform-core canonical controls must remain separate from provider-owned Microsoft bindings. +- Ambiguity must resolve as explicit `ambiguous`, never as a guessed winner. +- Detectability, evaluation strategy, and evidence archetypes belong directly on the control definition. +- First-slice adoption should be read-through rather than persistence-first. +- The seed catalog should stay bounded to a small set of high-value governance control families. + +## Phase 1 Design Summary + +- `research.md` records the architectural decisions that keep the slice narrow and provider-neutral at the control core. +- `data-model.md` defines the three core shapes: `CanonicalControlDefinition`, `MicrosoftSubjectBinding`, and `CanonicalControlResolutionResult`. +- `contracts/canonical-control-catalog.logical.openapi.yaml` defines the shared internal contract for catalog listing and control resolution. +- `quickstart.md` defines the narrow validation order and the intended code areas for the first slice. +- `tasks.md` sequences the work from seed catalog and resolver foundation through findings-derived evidence and tenant review adoption. + +## Phase 1 — Agent Context Update + +Run after artifact generation: + +- `.specify/scripts/bash/update-agent-context.sh copilot` + +## Implementation Strategy + +### Phase A — Seed the canonical control catalog + +**Goal**: Create one authoritative, product-seeded catalog with stable control keys and complete control metadata. + +| Step | File | Change | +|------|------|--------| +| A.1 | `apps/platform/config/canonical_controls.php` | Add the bounded canonical control seed catalog and Microsoft binding metadata. | +| A.2 | `apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php` and related enums/value objects | Model canonical control metadata, detectability classes, evaluation strategies, evidence archetypes, artifact suitability, and historical status. | +| A.3 | `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php` | Load, validate, and expose stable control definitions and binding metadata deterministically. | + +### Phase B — Implement provider-owned binding and shared resolution semantics + +**Goal**: Resolve canonical controls through one shared contract without letting Microsoft metadata become the control model. + +| Step | File | Change | +|------|------|--------| +| B.1 | `apps/platform/app/Support/Governance/Controls/MicrosoftSubjectBinding.php` | Model Microsoft workload, subject-family, signal, and supported-context binding metadata. | +| B.2 | `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionRequest.php` and `CanonicalControlResolutionResult.php` | Define the shared request and response primitives for `resolved`, `unresolved`, and `ambiguous` outcomes. | +| B.3 | `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php` | Implement deterministic context-aware resolution, unresolved handling, and explicit ambiguity. | + +### Phase C — Adopt the shared contract in the first-slice consumers + +**Goal**: Move current-release consumer adoption onto the shared control contract without widening the slice. + +| Step | File | Change | +|------|------|--------| +| C.1 | `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php` and `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php` | Resolve canonical control references inside findings-derived evidence composition. | +| C.2 | `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php` and `apps/platform/app/Models/EvidenceSnapshotItem.php` | Preserve transient resolved control metadata during evidence lookup and item payload consumption without introducing new canonical-control persistence ownership. | +| C.3 | `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, `TenantReviewSectionFactory.php`, and `TenantReviewService.php` | Reuse the shared resolver during tenant review composition and keep persistence derived. | + +### Phase D — Validate contract shape, scope discipline, and negative constraints + +**Goal**: Prove semantic correctness while keeping the slice narrow. + +| Step | File | Change | +|------|------|--------| +| D.1 | `apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php` and `CanonicalControlResolverTest.php` | Prove stable keys, metadata completeness, multi-binding behavior, unresolved outcomes, ambiguity, and retired-control handling. | +| D.2 | `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php` | Prove the logical contract shape stays aligned to the seed catalog and resolver rules. | +| D.3 | `apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php` and `apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php` | Prove first-slice consumers adopt the shared contract without local registries or fallback labels. | +| D.4 | `specs/236-canonical-control-catalog-foundation/quickstart.md` and `tasks.md` | Keep validation commands, paths, and no-Graph/no-sync guardrails explicit. | + +## Risks and Mitigations + +- **Provider-shaped drift**: Microsoft labels may accidentally become the canonical vocabulary. Mitigation: keep canonical control definitions and bindings structurally separate and test for provider-neutral keys and labels. +- **Consumer-scope drift**: It is easy to widen adoption into baseline, exception, or report surfaces prematurely. Mitigation: keep first-slice scope explicitly limited to findings-derived evidence and tenant review composition in the plan, spec, tasks, and validation notes. +- **Contract-shape drift**: The contract, data model, and seed metadata can diverge. Mitigation: keep the logical contract small, test it directly, and align fields such as `operator_description`, `binding_context`, and supported contexts explicitly. +- **Graph creep**: Future-looking catalog work can attract provider sync ideas too early. Mitigation: keep a documented no-Graph/no-sync guardrail in tasks and review focus. + +## Post-Design Re-check + +The feature remains constitution-compliant, Filament v5 and Livewire v4 compliant, and narrow. It introduces no new persistence, no new operator-facing page, no new Graph path, and no new operation type. The plan, research, data model, quickstart, contract, and tasks now align on one config-seeded catalog, one shared resolver, one provider-boundary rule, and one bounded first-slice consumer scope. diff --git a/specs/236-canonical-control-catalog-foundation/quickstart.md b/specs/236-canonical-control-catalog-foundation/quickstart.md new file mode 100644 index 00000000..d757fb1c --- /dev/null +++ b/specs/236-canonical-control-catalog-foundation/quickstart.md @@ -0,0 +1,68 @@ +# Quickstart: Canonical Control Catalog Foundation + +## Goal + +Implement the first canonical control core without introducing framework overlays, operator CRUD, or new provider runtime machinery. + +## Implementation Sequence + +1. Add the product-seeded canonical control registry and the supporting value objects. +2. Add provider-owned Microsoft subject and signal bindings. +3. Implement the shared resolution contract with explicit `resolved`, `unresolved`, and `ambiguous` outcomes. +4. Wire a bounded first-slice set of governance consumers to the shared contract. +5. Add focused unit and feature coverage proving convergence and ambiguity handling. + +## Suggested Code Areas + +```text +apps/platform/app/Support/Governance/Controls/ +apps/platform/config/ +apps/platform/app/Services/Evidence/ +apps/platform/app/Services/TenantReviews/ +apps/platform/tests/Unit/Governance/ +apps/platform/tests/Feature/Governance/ +apps/platform/tests/Feature/Evidence/ +apps/platform/tests/Feature/TenantReview/ +``` + +## Verification Commands + +Run the narrowest proving lane first: + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php +``` + +Then run the bounded integration proof: + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php +``` + +If PHP files were added or changed, finish with formatting: + +```bash +cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +## Review Focus + +- Confirm the control catalog remains provider-neutral at its core. +- Confirm Microsoft bindings are secondary metadata only. +- Confirm first-slice evidence and tenant review consumers do not invent feature-local control-family wording. +- Confirm ambiguity is explicit and never guessed. +- Confirm no Graph path or provider sync job slipped into the slice. +- Confirm no broad persistence or authoring UI slipped into the first slice. + +## Guardrail Close-Out + +- Validation completed: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php` + - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- Guardrails checked: + - No Graph client change. + - No `config/graph_contracts.php` change. + - No provider sync job. + - No feature-local control-family fallback or workload-first primary control vocabulary in the touched evidence and tenant review adoption paths. +- Bounded follow-up: none for this slice. diff --git a/specs/236-canonical-control-catalog-foundation/research.md b/specs/236-canonical-control-catalog-foundation/research.md new file mode 100644 index 00000000..f13c62ec --- /dev/null +++ b/specs/236-canonical-control-catalog-foundation/research.md @@ -0,0 +1,49 @@ +# Research: Canonical Control Catalog Foundation + +## Decision 1: Keep the first catalog product-seeded and config-backed + +- **Decision**: Model the first canonical control catalog as a product-seeded registry in repository configuration plus narrow value objects and resolvers, not as an operator-managed DB CRUD domain. +- **Rationale**: The current release needs one stable control identity more than it needs authoring workflow, lifecycle UI, or workspace-specific customization. A config-backed seed catalog keeps the control core small, reviewable, versioned with the code, and easy to exercise across multiple governance consumers. +- **Alternatives considered**: + - DB-backed control management UI: rejected because the current release has no operator workflow that requires live authoring, approval, archiving, or per-workspace overrides. + - Feature-local arrays inside each consumer: rejected because that would preserve the current semantic fragmentation. + +## Decision 2: Separate platform-core control definitions from provider-owned Microsoft bindings + +- **Decision**: Canonical control identity, taxonomy, detectability, evaluation semantics, and evidence suitability stay platform-core, while Microsoft workload, subject-family, and signal relationships remain provider-owned binding metadata. +- **Rationale**: The product is Microsoft-first today, but this spec exists partly to stop Microsoft semantics from becoming silent platform truth. The separation keeps the catalog framework-neutral and provider-neutral without inventing a speculative multi-provider runtime. +- **Alternatives considered**: + - Use Microsoft subject identifiers as the primary control key: rejected because it would make the provider the platform core. + - Create a generic provider-plugin framework now: rejected because there is only one real provider case today. + +## Decision 3: Make ambiguity explicit in the shared resolution contract + +- **Decision**: The shared resolver returns explicit `resolved`, `unresolved`, or `ambiguous` outcomes instead of guessing when one subject or signal could imply multiple controls. +- **Rationale**: A guessed mapping would silently misclassify governance meaning and poison later findings, review outputs, or evidence narratives. Explicit ambiguity is safer and easier to test. +- **Alternatives considered**: + - Always return the first matching control: rejected because ordering would become hidden truth. + - Allow consumers to choose different local fallback rules: rejected because it would recreate semantic drift. + +## Decision 4: Encode detectability, evaluation strategy, and evidence archetypes directly on each control + +- **Decision**: Each canonical control definition carries detectability class, evaluation strategy, evidence archetypes, and artifact suitability instead of leaving those semantics to later presentation layers. +- **Rationale**: The control core must explain what the product can prove, partially infer, attest, or only reference externally. Deferring that meaning to later overlays would force downstream consumers to invent their own truth. +- **Alternatives considered**: + - One generic verification flag: rejected because it collapses materially different control types into one misleading boolean. + - Consumer-specific interpretation rules: rejected because those rules would diverge immediately. + +## Decision 5: Keep first-slice consumer adoption derived rather than persistence-first + +- **Decision**: First-slice consumers resolve canonical control metadata on read through the shared contract instead of requiring immediate schema expansion across baseline, finding, evidence, exception, and review records. +- **Rationale**: The current need is control convergence, not a broad storage migration. Derived adoption proves the catalog against real workflows while keeping rollout narrow. +- **Alternatives considered**: + - Add `canonical_control_key` columns everywhere up front: rejected because it forces a broad migration before the model is proven. + - Leave all consumers untouched until a later reporting slice: rejected because then the catalog would exist without proving cross-domain value. + +## Decision 6: Start with a bounded seed catalog of high-value governance families + +- **Decision**: Seed only the control families already implied by the current product and roadmap, such as strong authentication, conditional access, privileged access, endpoint hardening or compliance, sharing boundaries, audit retention, and delegated admin boundaries. +- **Rationale**: The goal is a reviewable bridge layer, not exhaustive coverage. A bounded seed catalog is easier to validate and keeps the spec proportional. +- **Alternatives considered**: + - Exhaustive control library in the first release: rejected because it imports compliance-program scale before the control core is proven. + - Framework-shaped seeds such as CIS or NIS2 first: rejected because frameworks are downstream overlays, not the primary control ontology. \ No newline at end of file diff --git a/specs/236-canonical-control-catalog-foundation/spec.md b/specs/236-canonical-control-catalog-foundation/spec.md new file mode 100644 index 00000000..e475270e --- /dev/null +++ b/specs/236-canonical-control-catalog-foundation/spec.md @@ -0,0 +1,243 @@ +# Feature Specification: Canonical Control Catalog Foundation + +**Feature Branch**: `236-canonical-control-catalog-foundation` +**Created**: 2026-04-24 +**Status**: Approved +**Input**: User description: "Canonical Control Catalog Foundation" + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: TenantPilot already has real governance workflows across baselines, drift, findings, evidence, exceptions, and review packs, but it still lacks one shared canonical control object that those workflows can point at. +- **Today's failure**: The same technical control objective can be expressed differently in baseline logic, finding summaries, evidence interpretation, and later framework discussions, which blurs what the control actually is versus which Microsoft subject, workload, or evidence item currently supports it. +- **User-visible improvement**: Governance artifacts can converge on one stable control identity and one honest detectability story instead of each surface inventing local control meaning. +- **Smallest enterprise-capable version**: Introduce a product-seeded canonical control catalog with stable control keys, control metadata, detectability and evaluation semantics, evidence archetypes, Microsoft subject bindings, and one shared resolution contract consumed by existing governance builders. +- **Explicit non-goals**: No certification engine, no framework-first catalog, no full NIS2/BSI/ISO/COBIT library, no operator-managed CRUD UI for controls, no posture scoring, no second artifact store, and no broad Microsoft-domain expansion. +- **Permanent complexity imported**: One canonical control registry, one subject-binding model, one shared control-resolution contract, a small metadata family for detectability and evaluation semantics, and focused regression coverage for consumers. +- **Why now**: The roadmap and spec candidates place this as the next strategic bridge between the shipped governance engine and later readiness or customer-review work. +- **Why not local**: A local label or mapping inside one feature would keep control meaning fragmented and force every downstream surface to keep duplicating the same semantics. +- **Approval class**: Core Enterprise +- **Red flags triggered**: New source-of-truth risk and taxonomy risk. Defense: the first slice stays product-seeded, narrow, framework-neutral, and avoids authoring UI or speculative multi-provider machinery. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace +- **Primary Routes**: + - No new standalone route is required in the foundation slice. + - First-slice consumers remain on their current surfaces, specifically evidence snapshots and tenant review composition paths. Findings continue to feed the existing evidence pipeline on their current path, and any tenant review inspection remains downstream of already composed review data rather than a separate adoption target in this slice. +- **Data Ownership**: + - Canonical control definitions are product-seeded platform truth consumed safely within workspace-scoped governance workflows. + - Derived control references in the first slice remain owned by existing evidence snapshot and tenant review records that consume them. Findings remain feeder inputs rather than a direct canonical-control consumer surface in this slice. + - No new operator-managed tenant-owned entity is introduced in the first slice. +- **RBAC**: + - No new top-level capability is introduced for the first slice. + - Existing authorization on evidence and tenant review surfaces continues to gate any downstream control metadata shown through those surfaces in the first slice. + - The catalog foundation must not relax tenant or workspace isolation. + +## 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 +- **Interaction class(es)**: evidence viewers, tenant review composition, governance summaries, read-model composition +- **Systems touched**: findings-derived evidence composition, evidence snapshot composition, tenant review composition, and downstream inspection of already composed review data +- **Existing pattern(s) to extend**: existing governance summary builders and existing evidence or review composition paths +- **Shared contract / presenter / builder / renderer to reuse**: existing domain builders stay in place; they consume one new shared control-resolution contract rather than inventing local control labels +- **Why the existing shared path is sufficient or insufficient**: existing builders are sufficient for surface-specific formatting, but they are insufficient for cross-domain control identity because each builder currently has only local subject or evidence context +- **Allowed deviation and why**: none +- **Consistency impact**: control key, control label, detectability language, and evidence suitability semantics must remain identical wherever the shared control contract is consumed +- **Review focus**: reviewers should block any new consumer that bypasses the shared control catalog by inventing local control-family wording or workload-first labels + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: yes +- **Boundary classification**: mixed +- **Seams affected**: control taxonomy, governed-subject binding, control-resolution semantics, downstream operator vocabulary derived from canonical controls +- **Neutral platform terms preserved or introduced**: canonical control, control domain, control subdomain, control class, detectability class, evaluation strategy, evidence archetype, governed subject, provider binding +- **Provider-specific semantics retained and why**: Microsoft workload, subject-family, and signal bindings remain provider-owned metadata because the current product truth is Microsoft-first +- **Why this does not deepen provider coupling accidentally**: canonical control keys and primary control definitions remain framework-neutral and provider-neutral; Microsoft-specific bindings are attached as secondary metadata, not used as the primary control identity +- **Follow-up path**: none + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +N/A - no new operator-facing surface is required in the foundation slice. Existing surfaces may consume canonical control references through later adoption or small follow-through changes, but this spec does not add a new page, queue, or custom UI framework. + +## 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?**: yes +- **New cross-domain UI framework/taxonomy?**: yes +- **Current operator problem**: Operators, reviewers, and future customer-facing outputs do not yet have one stable answer to which control an artifact is about; the same objective can still be rephrased differently per workflow. +- **Existing structure is insufficient because**: governed-subject taxonomy explains what Microsoft object or subject family is in scope, but it does not define the higher-order control objective, its detectability class, or how evidence should be interpreted across domains. +- **Narrowest correct implementation**: use a product-seeded canonical control registry plus one shared resolution contract and keep the first adoption derived rather than introducing CRUD management or broad new persistence. +- **Ownership cost**: the seed catalog must be curated, binding rules must stay deterministic, and downstream consumer tests must prevent drift in control identity or detectability semantics. +- **Alternative intentionally rejected**: feature-local control labels and an immediate DB-backed authoring system were rejected because the former preserves fragmentation and the latter imports unnecessary lifecycle and UI complexity before the catalog proves itself. +- **Release truth**: current-release truth with deliberate preparation for later readiness and reporting overlays + +### 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**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: the first slice is primarily a deterministic catalog and resolution contract with a few bounded integration points; unit tests prove metadata and resolution rules, and focused feature tests prove downstream consumers do not fork control meaning locally +- **New or expanded test families**: targeted governance foundation tests only +- **Fixture / helper cost impact**: minimal; use seeded config or registry fixtures and existing baseline, finding, evidence, and review factories where integration coverage is needed +- **Heavy-family visibility / justification**: none; no browser or heavy-governance family is required for the first slice +- **Special surface test profile**: N/A +- **Standard-native relief or required special coverage**: ordinary feature coverage only +- **Reviewer handoff**: reviewers should confirm that lane choice stays narrow, no expensive shared helper defaults are introduced, and all downstream references come from the shared contract rather than local labels +- **Budget / baseline / trend impact**: none expected +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Resolve One Stable Control Identity (Priority: P1) + +As an operator or reviewer, I want governance artifacts that describe the same control objective to resolve to one stable canonical control so the product stops explaining the same issue differently per feature. + +**Why this priority**: This is the primary value of the foundation. Without stable control identity, later readiness, reporting, and customer review work will keep duplicating local semantics. + +**Independent Test**: Resolve the same governance objective through at least two existing consumer contexts and confirm the shared contract returns the same canonical control key and metadata. + +**Acceptance Scenarios**: + +1. **Given** two Microsoft subject families that represent the same governance objective, **When** the system resolves their canonical control references, **Then** both resolve to the same canonical control key and label. +2. **Given** a findings-derived evidence composition path and a tenant review consumer that point at the same governance objective, **When** both request canonical control metadata, **Then** both receive the same control identity and detectability semantics. + +--- + +### User Story 2 - Preserve Honest Detectability and Evidence Meaning (Priority: P1) + +As an operator preparing a governance review, I want the platform to distinguish direct-technical controls from indirect, attested, or external-evidence-only controls so later outputs do not over-claim what TenantPilot can prove automatically. + +**Why this priority**: Honest detectability is part of the product's trust contract. A canonical control layer that collapses all controls into one false verified or not-verified path would harm operator trust. + +**Independent Test**: Inspect seed controls with different detectability classes and verify each one carries explicit evaluation and evidence semantics. + +**Acceptance Scenarios**: + +1. **Given** a seed control that is only workflow-attested or external-evidence-only, **When** the control is resolved, **Then** the metadata explicitly marks that detectability class instead of implying direct technical verification. +2. **Given** a seed control with multiple allowed evidence archetypes, **When** a downstream consumer requests suitability metadata, **Then** the response identifies which evidence forms are valid for that control. + +--- + +### User Story 3 - Add Microsoft Bindings Without Making Microsoft the Control Model (Priority: P2) + +As a maintainer extending governance coverage, I want Microsoft workload and signal bindings to attach to canonical controls without turning service-specific labels into the platform's primary control vocabulary. + +**Why this priority**: The first provider is Microsoft, but the platform core must not become silently Microsoft-shaped. This story protects the boundary while still enabling real current-release bindings. + +**Independent Test**: Add or modify a Microsoft subject binding for a seeded control and confirm the canonical control definition stays unchanged while the binding metadata changes. + +**Acceptance Scenarios**: + +1. **Given** a canonical control already exists for a governance objective, **When** a new Microsoft subject family is bound to it, **Then** the system reuses the existing canonical control key instead of creating a duplicate control definition. +2. **Given** provider-specific subject or signal metadata changes, **When** the binding is updated, **Then** the platform-core control definition remains stable and provider-neutral. + +--- + +### User Story 4 - Prepare Later Readiness and Review Work Without Local Reinvention (Priority: P3) + +As a product maintainer, I want the first-slice evidence and tenant review consumers to have one defined path to canonical control metadata so later work does not invent its own framework or workload-specific control objects. + +**Why this priority**: This is the strategic bridge value of the spec. It keeps later slices smaller and prevents new semantic drift. + +**Independent Test**: Prove that the first-slice evidence and tenant review consumers can request canonical control metadata through one shared contract without adding local control-family registries. + +**Acceptance Scenarios**: + +1. **Given** an evidence or tenant review consumer is in the first adoption slice, **When** it needs control metadata, **Then** it uses the shared canonical control contract rather than feature-local labels or registries. +2. **Given** a framework-specific readiness or reporting slice is planned later, **When** it references control meaning, **Then** the canonical catalog remains the primary control layer and any framework mapping remains secondary. + +### Edge Cases + +- One Microsoft subject family can plausibly map to more than one control objective. +- A canonical control is valid for review packs and evidence only, but not for direct baseline or drift evaluation. +- A control is retired for new use, but existing downstream references still point to its stable key. +- A downstream consumer asks for canonical control metadata without a valid subject binding. +- Two provider-owned bindings point to one canonical control while using different signal shapes. +- A future framework mapping attempts to redefine canonical control identity instead of layering on top of it. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This foundation does not add Microsoft Graph calls, destructive actions, or a new operator-facing run flow. The first slice remains read-focused and in-process. If later follow-through work introduces writes, runs, or new surfaces, those slices must define their own safety and observability contract explicitly. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature intentionally introduces one new canonical control taxonomy and one shared resolution contract because governed-subject vocabulary alone cannot safely carry control meaning. The first slice avoids DB-backed control authoring, avoids framework overlays, and keeps consumer adoption derived before persistence. + +**Constitution alignment (XCUT-001):** This feature touches cross-cutting governance summaries plus first-slice evidence and tenant review consumers. Those consumers must continue to use their existing builders and presentation paths, but control identity and detectability semantics must come from the shared canonical control contract. + +**Constitution alignment (PROV-001):** The canonical control catalog is platform-core. Microsoft workload and signal bindings are provider-owned metadata. Provider-specific semantics must remain secondary and must not replace canonical control keys or vocabulary. + +**Constitution alignment (TEST-GOV-001):** Coverage stays in narrow unit and feature lanes. No new heavy browser or broad surface family is justified for the first slice. + +**Constitution alignment (OPS-UX):** Not applicable in the foundation slice because no new `OperationRun` is required. + +**Constitution alignment (RBAC-UX):** No authorization boundary changes are introduced. Existing capabilities continue to guard any consumer surfaces that later display canonical control metadata. + +**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. + +**Constitution alignment (BADGE-001):** The first slice does not add new badge families. If later consumers render detectability or suitability as badges, they must do so through centralized badge semantics in a follow-through slice. + +**Constitution alignment (UI-FIL-001):** The foundation slice does not require new Filament UI. + +**Constitution alignment (UI-NAMING-001):** Canonical control vocabulary must remain stable across future consumer surfaces. Provider or workload names are secondary descriptors only. + +**Constitution alignment (DECIDE-001):** No new decision surface is added in the foundation slice. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** Not applicable in the foundation slice because no new operator-facing surface is added. + +**Constitution alignment (ACTSURF-001 - action hierarchy):** Not applicable. + +**Constitution alignment (OPSURF-001):** Not applicable in the foundation slice because there is no new operator-facing page. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature adds one semantic layer because direct domain-to-UI mapping is insufficient across baseline, finding, evidence, and review workflows without a shared control identity. The catalog and resolver must remain the single source for this meaning, and downstream tests must focus on business truth rather than thin wrappers. + +### Functional Requirements + +- **FR-236-001 Authoritative canonical control catalog**: The system MUST maintain one authoritative canonical control catalog for the first slice with stable canonical control keys that are independent of provider identifiers, framework clause IDs, and individual workload payload shapes, and it MUST support internal listing of seeded control definitions for inspection and validation. +- **FR-236-002 Control definition metadata**: Each canonical control definition MUST include, at minimum, a stable key, canonical name, control domain, control subdomain, control class, descriptive summary, and operator-safe explanation of what the control is about. +- **FR-236-003 Detectability semantics**: Each canonical control definition MUST declare a detectability class that distinguishes at least direct-technical, indirect-technical, workflow-attested, and external-evidence-only controls. +- **FR-236-004 Evaluation semantics**: Each canonical control definition MUST declare an evaluation strategy that explains how the product should reason about the control without collapsing all controls into one universal compliant or non-compliant path. +- **FR-236-005 Evidence archetypes**: Each canonical control definition MUST declare at least one evidence archetype and MAY declare more than one valid evidence archetype. +- **FR-236-006 Artifact suitability**: Each canonical control definition MUST declare whether it is baseline-capable, drift-capable, finding-capable, exception-capable, evidence-capable, review-capable, and report-capable. +- **FR-236-007 Microsoft subject binding model**: The system MUST support provider-owned Microsoft bindings that connect one canonical control to one or more Microsoft subject families, workloads, or signal sources without redefining the control itself. +- **FR-236-008 Provider-neutral control identity**: Provider-specific subject metadata MUST NOT be the canonical control primary key or replace the provider-neutral control definition. +- **FR-236-009 Multi-binding support**: One canonical control MUST be able to bind to multiple Microsoft subject families or signals. +- **FR-236-010 Ambiguity handling**: If a governed subject or signal maps ambiguously to multiple canonical controls without an explicitly declared primary relationship for the current context, the resolver MUST fail deterministically rather than guessing. +- **FR-236-011 Shared resolution contract**: The platform MUST provide one shared resolution contract that lets downstream governance consumers request canonical control metadata using current governed-subject or signal context. +- **FR-236-012 Consumer convergence path**: Findings-derived evidence composition and tenant review consumers in scope for first adoption MUST be able to consume canonical control metadata through the shared contract instead of defining local control-family truth. +- **FR-236-013 Seed catalog breadth**: The first slice MUST ship with a bounded seed catalog covering a small set of high-value control families relevant to the current governance product, including strong authentication, conditional access, privileged access, sharing or boundary controls, endpoint hardening or compliance, audit retention, and delegated admin boundaries. +- **FR-236-014 No framework-first primary shape**: Framework overlays such as NIS2, BSI, ISO, COBIT, or CIS MUST NOT be the primary shape of the canonical catalog in the first slice. +- **FR-236-015 Honest non-direct coverage**: The system MUST represent controls that are not directly technically verifiable without implying that they are directly evaluated by the product. +- **FR-236-016 Stable historical reference**: A canonical control key once shipped MUST remain stable for downstream artifacts to reference it consistently, even if the control later becomes retired for new use. +- **FR-236-017 Missing binding failure safety**: If a downstream consumer requests canonical control metadata for a subject or signal with no valid binding, the system MUST return an explicit unresolved result rather than inventing a local fallback control label. +- **FR-236-018 Narrow rollout model**: The first slice MUST stay product-seeded and MUST NOT require operator-managed CRUD authoring for controls. +- **FR-236-019 No new Graph path**: The first slice MUST NOT introduce Microsoft Graph calls or a provider synchronization job for catalog resolution. +- **FR-236-020 Platform vocabulary**: Shared platform contracts introduced by this feature MUST use canonical control and governed-subject vocabulary rather than workload-specific or framework-specific names as their primary language. + +### Key Entities *(include if feature involves data)* + +- **Canonical Control Definition**: The product-owned description of one stable governance control objective, including its identity, taxonomy placement, detectability semantics, evaluation strategy, evidence archetypes, and suitability metadata. +- **Microsoft Subject Binding**: Provider-owned metadata that links Microsoft subject families, workloads, or signal sources to one canonical control without changing the control's primary identity. +- **Canonical Control Resolution Result**: The shared contract outcome that returns either a resolved canonical control reference or an explicit unresolved or ambiguous result for downstream consumers. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-236-001**: For every seed control in the first slice, 100% of catalog entries include control domain, subdomain, control class, detectability class, evaluation strategy, and at least one evidence archetype. +- **SC-236-002**: The same governance objective resolved through at least two targeted first-slice consumer contexts returns one identical canonical control key and label. +- **SC-236-003**: 100% of first-slice evidence and tenant review integrations use the shared canonical control contract and do not introduce feature-local control-family registries or fallback labels. +- **SC-236-004**: A new Microsoft subject binding for an already-modeled governance objective can be added without creating a duplicate canonical control definition. \ No newline at end of file diff --git a/specs/236-canonical-control-catalog-foundation/tasks.md b/specs/236-canonical-control-catalog-foundation/tasks.md new file mode 100644 index 00000000..f9c68476 --- /dev/null +++ b/specs/236-canonical-control-catalog-foundation/tasks.md @@ -0,0 +1,256 @@ +# Tasks: Canonical Control Catalog Foundation + +**Input**: Design documents from `/specs/236-canonical-control-catalog-foundation/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/canonical-control-catalog.logical.openapi.yaml`, `quickstart.md` + +**Tests**: Required. This feature changes shared governance semantics and downstream read-model composition, so Pest coverage must be added or extended in `apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php`, `apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php`, `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php`, and `apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`. +**Operations**: No new `OperationRun` type is introduced. Tasks that touch `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php` or `apps/platform/app/Services/TenantReviews/TenantReviewService.php` must preserve existing run ownership, notifications, and audit behavior instead of creating a new catalog-specific workflow. +**RBAC**: No new capability or route is introduced. Existing authorization on evidence and tenant review surfaces must remain tenant-safe, and any downstream control metadata must stay behind the current evidence and review authorization paths. +**UI Naming**: No new operator-facing action surface is added. If any evidence or review copy changes, canonical control vocabulary must be primary and provider or workload labels must remain secondary descriptors. +**Cross-Cutting Shared Pattern Reuse**: Extend the existing governance summary and composition paths in `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php`, `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` before introducing any feature-local control registry or formatter. +**Provider Boundary / Platform Core**: Platform-core control identity, taxonomy, detectability, and evidence suitability live in `apps/platform/app/Support/Governance/Controls/` and `apps/platform/config/canonical_controls.php`. Microsoft workload, subject-family, and signal metadata remain secondary binding data and must not become the primary key or vocabulary for canonical controls. +**UI / Surface Guardrails**: `N/A` for new surfaces. This slice is `report-only` for existing evidence and review composition surfaces and must not introduce a new page, wizard, or custom Filament contract. +**Filament UI Action Surfaces**: No new Filament Resource, RelationManager, or Page action is introduced. Existing global-search posture remains unchanged because no new Resource is added in this slice. +**Badges**: No new badge domain or badge-mapping family is introduced. + +**Organization**: Tasks are grouped by user story so each slice stays independently testable. Recommended delivery order is `US1`, `US2`, `US3`, then `US4`, because consumer adoption depends on the shared catalog, metadata semantics, and provider-binding behavior being stable first. + +## 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 no heavy-governance or browser lane is introduced. +- [X] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; seeded config drives the catalog. +- [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 (Shared Anchors) + +**Purpose**: Lock the implementation anchors, consumer scope, and narrow proving commands before adding the canonical control core. + +- [X] T001 [P] Verify the feature anchor inventory across `apps/platform/app/Support/Governance/`, `apps/platform/app/Services/Evidence/`, `apps/platform/app/Services/TenantReviews/`, and `apps/platform/config/canonical_controls.php` +- [X] T002 [P] Create the Spec 236 proving entry points in `apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php`, `apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php`, `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php`, and `apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php` +- [X] T003 [P] Confirm the narrow validation commands and first-slice consumer scope in `specs/236-canonical-control-catalog-foundation/spec.md` and `specs/236-canonical-control-catalog-foundation/quickstart.md` + +**Checkpoint**: Runtime anchors and proof entry points are fixed before implementation starts. + +--- + +## Phase 2: Foundational (Blocking Control Core) + +**Purpose**: Establish the product-seeded catalog and shared resolution primitives that every user story depends on. + +**CRITICAL**: No user story work should begin until this phase is complete. + +- [X] T004 [P] Create the bounded seed-catalog configuration anchor in `apps/platform/config/canonical_controls.php` +- [X] T005 [P] Create canonical control metadata types in `apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php`, `apps/platform/app/Support/Governance/Controls/DetectabilityClass.php`, `apps/platform/app/Support/Governance/Controls/EvaluationStrategy.php`, `apps/platform/app/Support/Governance/Controls/EvidenceArchetype.php`, and `apps/platform/app/Support/Governance/Controls/ArtifactSuitability.php` +- [X] T006 [P] Create provider-binding and resolution-contract primitives in `apps/platform/app/Support/Governance/Controls/MicrosoftSubjectBinding.php`, `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionRequest.php`, and `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionResult.php` +- [X] T007 [P] Create shared catalog and resolver service shells in `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php` and `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php` +- [X] T008 [P] Inventory first-slice downstream adoption seams in `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`, `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` so consumer adoption stays derived and local registries are forbidden + +**Checkpoint**: The repo has one shared catalog namespace, one config-backed seed source, and one explicit downstream adoption boundary. + +--- + +## Phase 3: User Story 1 - Resolve One Stable Control Identity (Priority: P1) 🎯 MVP + +**Goal**: Resolve the same governance objective to one stable canonical control key and label across supported contexts. + +**Independent Test**: Resolve the same objective through multiple subject families and consumer contexts and confirm the shared contract returns one identical canonical control key and canonical label. + +### Tests for User Story 1 + +- [X] T009 [P] [US1] Add stable-key, canonical-label, and same-objective convergence coverage in `apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php` and `apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php` +- [X] T010 [P] [US1] Add shared logical-contract coverage for catalog listing and resolution shapes in `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php` + +### Implementation for User Story 1 + +- [X] T011 [US1] Populate stable canonical control keys, names, domains, subdomains, classes, summaries, operator descriptions, and `historical_status` in `apps/platform/config/canonical_controls.php` +- [X] T012 [US1] Implement deterministic catalog loading, lookup, and historical-key stability in `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php` +- [X] T013 [US1] Implement shared canonical control resolution by provider, subject family, workload, signal, and consumer context in `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php`, `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionRequest.php`, and `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionResult.php` + +**Checkpoint**: User Story 1 is independently functional and stable canonical control identity no longer depends on feature-local naming. + +--- + +## Phase 4: User Story 2 - Preserve Honest Detectability and Evidence Meaning (Priority: P1) + +**Goal**: Ensure each canonical control carries explicit detectability, evaluation, and evidence semantics so downstream consumers do not over-claim proof. + +**Independent Test**: Resolve controls with direct, indirect, workflow-attested, and external-evidence-only semantics and confirm the returned metadata preserves those distinctions and allowed evidence forms. + +### Tests for User Story 2 + +- [X] T014 [P] [US2] Add detectability, evaluation-strategy, evidence-archetype, artifact-suitability, and required seed-family coverage in `apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php` +- [X] T015 [P] [US2] Add resolved-metadata contract coverage for honest detectability and suitability semantics in `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php` + +### Implementation for User Story 2 + +- [X] T016 [US2] Expand every seed definition with detectability, evaluation, evidence-archetype, and artifact-suitability metadata in `apps/platform/config/canonical_controls.php` +- [X] T017 [US2] Enforce metadata completeness and narrow validation failures in `apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php` and `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php` +- [X] T018 [US2] Expose downstream-safe detectability and suitability metadata through `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionResult.php` and `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php` + +**Checkpoint**: User Story 2 is independently functional and the shared contract carries honest proof semantics instead of a false universal verification model. + +--- + +## Phase 5: User Story 3 - Add Microsoft Bindings Without Making Microsoft the Control Model (Priority: P2) + +**Goal**: Attach Microsoft workload, subject-family, and signal bindings to canonical controls while keeping provider-neutral control identity primary. + +**Independent Test**: Add or modify Microsoft binding metadata for a seeded control and confirm the canonical control definition stays stable, duplicate controls are not created, and ambiguous cases fail deterministically. + +### Tests for User Story 3 + +- [X] T019 [P] [US3] Add multi-binding, provider-neutral identity, unresolved, ambiguous, retired-control, and `historical_status` coverage in `apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php` +- [X] T020 [P] [US3] Add workload, signal, and context-primary integration coverage for no-guess resolution in `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php` + +### Implementation for User Story 3 + +- [X] T021 [US3] Model provider-owned Microsoft bindings, supported contexts, primary flags, and notes in `apps/platform/config/canonical_controls.php` and `apps/platform/app/Support/Governance/Controls/MicrosoftSubjectBinding.php` +- [X] T022 [US3] Implement binding selection, context-primary resolution, and deterministic unresolved or ambiguous reason codes in `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php` +- [X] T023 [US3] Keep Microsoft metadata secondary and provider-neutral vocabulary primary across `apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php`, `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php`, and `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionResult.php` + +**Checkpoint**: User Story 3 is independently functional and Microsoft bindings extend coverage without becoming the control model. + +--- + +## Phase 6: User Story 4 - Prepare Later Readiness and Review Work Without Local Reinvention (Priority: P3) + +**Goal**: Give first-slice downstream consumers one shared path to canonical control metadata so evidence and review composition stop inventing local control families. + +**Independent Test**: Generate evidence and compose a tenant review, then confirm both paths consume canonical control metadata through the shared resolver without adding a local registry or fallback label. + +### Tests for User Story 4 + +- [X] T024 [P] [US4] Add evidence-snapshot control-reference coverage proving shared canonical-control contract use and no local fallback labels in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php` +- [X] T025 [P] [US4] Add tenant-review composition control-reference coverage proving shared canonical-control contract use and no local fallback labels in `apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php` + +### Implementation for User Story 4 + +- [X] T026 [US4] Resolve canonical control references inside findings-derived evidence composition in `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php` and `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php` +- [X] T027 [US4] Preserve transient shared control metadata on evidence lookup and snapshot item payload consumption without introducing new canonical-control persistence ownership in `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php` and `apps/platform/app/Models/EvidenceSnapshotItem.php` +- [X] T028 [US4] Reuse shared control resolution during review composition instead of local control wording in `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php` and `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` +- [X] T029 [US4] Keep tenant review orchestration derived and persistence-neutral while passing canonical control context through `apps/platform/app/Services/TenantReviews/TenantReviewService.php` and `apps/platform/app/Models/TenantReview.php` + +**Checkpoint**: User Story 4 is independently functional and first-slice consumers have one shared control-resolution path. + +--- + +## Phase 7: Polish & Cross-Cutting Validation + +**Purpose**: Remove local semantic drift, run the narrow proving lanes, and close the feature with explicit guardrail notes. + +- [X] T030 [P] Search `apps/platform/app/Support/Governance/Controls/`, `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`, `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewService.php` to confirm no feature-local control-family fallback, workload-first primary vocabulary, or framework-first primary control shape remains +- [X] T031 Run the fast-feedback unit lane from `specs/236-canonical-control-catalog-foundation/quickstart.md` with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php` +- [X] T032 [P] Run the confidence feature lane from `specs/236-canonical-control-catalog-foundation/quickstart.md` with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php` +- [X] T033 Run formatting for touched PHP and test files with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- [X] T034 Record the Guardrail close-out entry, validation commands, and any bounded follow-up note in `specs/236-canonical-control-catalog-foundation/quickstart.md` and the active PR description +- [X] T035 [P] Confirm this slice introduces no Graph client change, no `config/graph_contracts.php` change, and no provider sync job by searching `apps/platform/app/`, `apps/platform/config/graph_contracts.php`, and `apps/platform/app/Jobs/` before merge + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies; can start immediately. +- **Foundational (Phase 2)**: Depends on Setup completion and blocks all story work. +- **User Story 1 (Phase 3)**: Depends on Foundational completion and is the MVP cut. +- **User Story 2 (Phase 4)**: Depends on User Story 1 because honest detectability metadata builds on the shared canonical identity and resolver contract. +- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because provider-owned bindings must resolve the full canonical metadata set. +- **User Story 4 (Phase 6)**: Depends on User Story 1 through User Story 3 because downstream consumers should adopt the final shared catalog and binding behavior instead of an interim contract. +- **Polish (Phase 7)**: Depends on all completed story work. + +### User Story Dependencies + +- **US1**: No dependency beyond Foundational. +- **US2**: Depends on US1 shared catalog and resolver behavior. +- **US3**: Depends on US1 and US2 shared identity plus metadata semantics. +- **US4**: Depends on US1, US2, and US3 to keep downstream consumer adoption on the final shared contract. + +### Within Each User Story + +- Write the story tests first and confirm they fail before implementation is considered complete. +- Keep the catalog product-seeded and in-repo; do not introduce DB-backed control authoring or migrations. +- Keep provider-specific binding metadata secondary to canonical control identity. +- Keep consumer adoption derived in evidence and review composition; do not add feature-local registries or fallback labels. +- Finish story-level validation before moving to the next dependent story. + +### Parallel Opportunities + +- `T001`, `T002`, and `T003` can run in parallel during Setup. +- `T004`, `T005`, `T006`, `T007`, and `T008` can run in parallel during Foundational work. +- `T009` and `T010` can run in parallel for User Story 1 before `T011` through `T013`. +- `T014` and `T015` can run in parallel for User Story 2 before `T016` through `T018`. +- `T019` and `T020` can run in parallel for User Story 3 before `T021` through `T023`. +- `T024` and `T025` can run in parallel for User Story 4 before `T026` through `T029`. +- `T031`, `T032`, and `T033` can run in parallel during final validation once implementation is complete. + +--- + +## Parallel Example: User Story 1 + +```bash +# User Story 1 proof in parallel +T009 apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php +T010 apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php +``` + +## Parallel Example: User Story 2 + +```bash +# User Story 2 proof in parallel +T014 apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php +T015 apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php +``` + +## Parallel Example: User Story 3 + +```bash +# User Story 3 proof in parallel +T019 apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php +T020 apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php +``` + +## Parallel Example: User Story 4 + +```bash +# User Story 4 proof in parallel +T024 apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php +T025 apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.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. Run `T031` before widening the slice. + +### Incremental Delivery + +1. Ship US1 to establish one stable canonical control identity and shared resolver contract. +2. Ship US2 to make detectability and evidence meaning explicit and honest. +3. Ship US3 to add Microsoft bindings without turning Microsoft semantics into platform truth. +4. Ship US4 to move evidence and tenant review composition onto the shared control contract. +5. Finish with final validation, formatting, and close-out notes from Phase 7. + +### Parallel Team Strategy + +1. One contributor can prepare the config-backed catalog and metadata primitives while another prepares the dedicated test files. +2. After Foundation is complete, one contributor can take US1 or US2 while another prepares US3 test coverage against the shared resolver. +3. Once the shared resolver is stable, evidence adoption and tenant review adoption can proceed in parallel inside US4. + +--- + +## Notes + +- `[P]` tasks target different files or independent proof surfaces and can be worked in parallel once upstream blockers are cleared. +- `[US1]`, `[US2]`, `[US3]`, and `[US4]` map directly to the feature specification user stories. +- The logical contract already exists in `specs/236-canonical-control-catalog-foundation/contracts/canonical-control-catalog.logical.openapi.yaml`; implementation tasks keep runtime behavior aligned to that shape rather than creating a public HTTP surface. +- The suggested MVP scope is Phase 1 through Phase 3 only. -- 2.45.2 From bd26e209de38873f0369db09b14cf43f9adbe16c Mon Sep 17 00:00:00 2001 From: ahmido Date: Fri, 24 Apr 2026 21:05:37 +0000 Subject: [PATCH 10/36] feat: harden provider boundaries (#273) ## Summary - add the provider boundary catalog, boundary support types, and guardrails for platform-core versus provider-owned seams - harden provider gateway, identity resolution, operation registry, and start-gate behavior to require explicit provider bindings - add unit and feature coverage for boundary classification, runtime preservation, unsupported paths, and platform-core leakage guards - add the full Spec Kit artifact set for spec 237 and update roadmap/spec-candidate tracking ## Validation - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderBoundaryClassificationTest.php tests/Unit/Providers/ProviderBoundaryGuardrailTest.php tests/Feature/Providers/ProviderBoundaryHardeningTest.php tests/Feature/Providers/UnsupportedProviderBoundaryPathTest.php tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderGatewayTest.php tests/Unit/Providers/ProviderIdentityResolverTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php` - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - browser smoke: `http://localhost/admin/provider-connections?tenant_id=18000000-0000-4000-8000-000000000180` loaded with the local smoke user, the empty-state CTA reached the canonical create route, and cancel returned to the scoped list Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/273 --- .github/agents/copilot-instructions.md | 4 +- .specify/memory/constitution.md | 88 ++++-- .specify/templates/checklist-template.md | 7 + .specify/templates/plan-template.md | 15 +- .specify/templates/spec-template.md | 21 +- .specify/templates/tasks-template.md | 11 +- .../Services/Providers/ProviderGateway.php | 15 +- .../Providers/ProviderIdentityResolution.php | 20 -- .../Providers/ProviderOperationRegistry.php | 168 +++++++++++- .../Providers/ProviderOperationStartGate.php | 62 ++++- .../Boundary/ProviderBoundaryCatalog.php | 194 ++++++++++++++ .../Boundary/ProviderBoundaryOwner.php | 17 ++ .../Boundary/ProviderBoundarySeam.php | 149 +++++++++++ .../Support/Providers/ProviderReasonCodes.php | 4 + .../Providers/ProviderReasonTranslator.php | 10 +- apps/platform/config/provider_boundaries.php | 115 ++++++++ .../ProviderBoundaryPlatformCoreGuardTest.php | 47 ++++ .../ProviderBoundaryHardeningTest.php | 109 ++++++++ .../UnsupportedProviderBoundaryPathTest.php | 45 ++++ .../ProviderBoundaryClassificationTest.php | 57 ++++ .../ProviderBoundaryGuardrailTest.php | 75 ++++++ .../Unit/Providers/ProviderGatewayTest.php | 28 ++ .../ProviderIdentityResolverTest.php | 21 ++ .../ProviderOperationStartGateTest.php | 38 +++ docs/product/roadmap.md | 2 +- docs/product/spec-candidates.md | 164 +++++++++--- docs/product/standards/README.md | 4 +- .../checklists/requirements.md | 35 +++ ...er-boundary-hardening.logical.openapi.yaml | 207 ++++++++++++++ .../data-model.md | 115 ++++++++ specs/237-provider-boundary-hardening/plan.md | 253 ++++++++++++++++++ .../quickstart.md | 84 ++++++ .../research.md | 42 +++ specs/237-provider-boundary-hardening/spec.md | 235 ++++++++++++++++ .../237-provider-boundary-hardening/tasks.md | 226 ++++++++++++++++ 35 files changed, 2587 insertions(+), 100 deletions(-) create mode 100644 apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php create mode 100644 apps/platform/app/Support/Providers/Boundary/ProviderBoundaryOwner.php create mode 100644 apps/platform/app/Support/Providers/Boundary/ProviderBoundarySeam.php create mode 100644 apps/platform/config/provider_boundaries.php create mode 100644 apps/platform/tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php create mode 100644 apps/platform/tests/Feature/Providers/ProviderBoundaryHardeningTest.php create mode 100644 apps/platform/tests/Feature/Providers/UnsupportedProviderBoundaryPathTest.php create mode 100644 apps/platform/tests/Unit/Providers/ProviderBoundaryClassificationTest.php create mode 100644 apps/platform/tests/Unit/Providers/ProviderBoundaryGuardrailTest.php create mode 100644 specs/237-provider-boundary-hardening/checklists/requirements.md create mode 100644 specs/237-provider-boundary-hardening/contracts/provider-boundary-hardening.logical.openapi.yaml create mode 100644 specs/237-provider-boundary-hardening/data-model.md create mode 100644 specs/237-provider-boundary-hardening/plan.md create mode 100644 specs/237-provider-boundary-hardening/quickstart.md create mode 100644 specs/237-provider-boundary-hardening/research.md create mode 100644 specs/237-provider-boundary-hardening/spec.md create mode 100644 specs/237-provider-boundary-hardening/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index e96aa26a..ce0cb817 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -250,6 +250,8 @@ ## Active Technologies - Existing PostgreSQL tables only; no new table or schema migration is planned in the mainline slice (235-baseline-capture-truth) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing governance domain models and builders, existing Evidence Snapshot and Tenant Review infrastructure (236-canonical-control-catalog-foundation) - PostgreSQL for existing downstream governance artifacts plus a product-seeded in-repo canonical control registry; no new DB-backed control authoring table in the first slice (236-canonical-control-catalog-foundation) +- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4 (237-provider-boundary-hardening) +- Existing PostgreSQL tables such as `provider_connections` and `operation_runs`; one new in-repo config catalog for provider-boundary ownership; no new database tables (237-provider-boundary-hardening) - PHP 8.4.15 (feat/005-bulk-operations) @@ -284,9 +286,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 237-provider-boundary-hardening: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4 - 236-canonical-control-catalog-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing governance domain models and builders, existing Evidence Snapshot and Tenant Review infrastructure - 235-baseline-capture-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces -- 234-dead-transitional-residue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests ### Pre-production compatibility check diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index c4431b04..d584fe80 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,28 +1,30 @@ ### Pre-production compatibility check diff --git a/.github/skills/spec-kit-one-shot-prep/SKILL.md b/.github/skills/spec-kit-one-shot-prep/SKILL.md new file mode 100644 index 00000000..4358e90e --- /dev/null +++ b/.github/skills/spec-kit-one-shot-prep/SKILL.md @@ -0,0 +1,294 @@ +--- +name: spec-kit-one-shot-prep +description: Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks. +--- + + + +Define the functionality provided by this skill, including detailed instructions and examples +--- +name: spec-kit-one-shot-prep +description: Create Spec Kit preparation artifacts in one pass for TenantPilot/TenantAtlas features: spec.md, plan.md, and tasks.md. Use for feature ideas, roadmap items, spec candidates, governance/platform improvements, UX improvements, cleanup candidates, and repo-based preparation before manual analysis or implementation. This skill must not implement application code. +--- + +# Skill: Spec Kit One-Shot Preparation + +## Purpose + +Use this skill to create a complete Spec Kit preparation package for a new TenantPilot/TenantAtlas feature in one pass: + +1. `spec.md` +2. `plan.md` +3. `tasks.md` + +This skill prepares implementation work, but it must not perform implementation. + +The intended workflow is: + +```text +feature idea / roadmap item / spec candidate +→ one-shot spec + plan + tasks preparation +→ manual repo-based analysis/review +→ explicit implementation step later +``` + +## When to Use + +Use this skill when the user asks to create or prepare Spec Kit artifacts from: + +- a feature idea +- a spec candidate +- a roadmap item +- a product or UX requirement +- a governance/platform improvement +- an architecture cleanup candidate +- a refactoring preparation request +- a TenantPilot/TenantAtlas implementation idea that should first become a formal spec + +Typical user prompts: + +```text +Mach daraus spec, plan und tasks in einem Rutsch. +``` + +```text +Erstelle daraus eine neue Spec Kit Vorbereitung, aber noch nicht implementieren. +``` + +```text +Nimm diesen spec candidate und bereite spec/plan/tasks vor. +``` + +```text +Erzeuge die Spec Kit Artefakte, danach mache ich die Analyse manuell. +``` + +## Hard Rules + +- Work strictly repo-based. +- Do not implement application code. +- Do not modify production code. +- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task. +- Do not execute implementation commands. +- Do not run destructive commands. +- Do not expand scope beyond the provided feature idea. +- Do not invent architecture that conflicts with repository truth. +- Do not create broad platform rewrites when a smaller implementable spec is possible. +- Prefer small, reviewable, implementation-ready specs. +- Preserve TenantPilot/TenantAtlas terminology. +- Follow the repository constitution and existing Spec Kit conventions. +- If repository truth conflicts with the user-provided draft, keep repository truth and document the deviation. +- If the feature is too broad, split it into one primary spec and optional follow-up spec candidates. + +## Required Inputs + +The user should provide at least one of: + +- feature title and short goal +- full spec candidate +- roadmap item +- rough problem statement +- UX or architecture improvement idea + +If the input is incomplete, proceed with the smallest reasonable interpretation and document assumptions. Do not block on clarification unless the request is impossible to scope safely. + +## Required Repository Checks + +Before creating or updating Spec Kit artifacts, inspect the relevant repository sources. + +Always check: + +1. `.specify/memory/constitution.md` +2. `.specify/templates/` +3. `specs/` +4. `docs/product/spec-candidates.md` +5. relevant roadmap documents under `docs/product/` +6. nearby existing specs with related terminology or scope + +Check application code only as needed to avoid wrong naming, wrong architecture, or duplicate concepts. Do not edit application code. + +## Spec Directory Rules + +Create a new spec directory using the next valid spec number and a kebab-case slug: + +```text +specs/-/ +``` + +The exact number must be derived from the current repository state and existing numbering conventions. + +Create or update only these preparation artifacts inside the selected spec directory: + +```text +specs/-/spec.md +specs/-/plan.md +specs/-/tasks.md +``` + +If the repository templates require additional preparation files, create them only when this is consistent with existing Spec Kit conventions. Do not create implementation files. + +## `spec.md` Requirements + +The spec must be product- and behavior-oriented. It should avoid premature implementation detail unless needed for correctness. + +Include: + +- Feature title +- Problem statement +- Business/product value +- Primary users/operators +- User stories +- Functional requirements +- Non-functional requirements +- UX requirements +- RBAC/security requirements +- Auditability/observability requirements +- Data/truth-source requirements where relevant +- Out of scope +- Acceptance criteria +- Success criteria +- Risks +- Assumptions +- Open questions + +TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles: + +- workspace/tenant isolation +- capability-first RBAC +- auditability +- operation/result truth separation +- source-of-truth clarity +- calm enterprise operator UX +- progressive disclosure where useful +- no false positive calmness + +## `plan.md` Requirements + +The plan must be repo-aware and implementation-oriented, but still must not implement. + +Include: + +- Technical approach +- Existing repository surfaces likely affected +- Domain/model implications +- UI/Filament implications +- Livewire implications where relevant +- OperationRun/monitoring implications where relevant +- RBAC/policy implications +- Audit/logging/evidence implications where relevant +- Data/migration implications where relevant +- Test strategy +- Rollout considerations +- Risk controls +- Implementation phases + +The plan should clearly distinguish: + +- execution truth +- artifact truth +- backup/snapshot truth +- recovery/evidence truth +- operator next action + +Use those distinctions only where relevant to the feature. + +## `tasks.md` Requirements + +Tasks must be ordered, small, and verifiable. + +Include: + +- checkbox tasks +- phase grouping +- tests before or alongside implementation tasks where practical +- final validation tasks +- documentation/update tasks if needed +- explicit non-goals where useful + +Avoid vague tasks such as: + +```text +Clean up code +Refactor UI +Improve performance +Make it enterprise-ready +``` + +Prefer concrete tasks such as: + +```text +- [ ] Add a feature test covering workspace isolation for . +- [ ] Update to display . +- [ ] Add policy coverage for . +``` + +If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths. + +## Scope Control + +If the requested feature implies multiple independent concerns, create one primary spec for the smallest valuable slice and add a `Follow-up spec candidates` section. + +Examples of follow-up candidates: + +- assigned findings +- pending approvals +- personal work queue +- notification delivery settings +- evidence pack export hardening +- operation monitoring refinements +- autonomous governance decision surfaces + +Do not force all follow-up candidates into the primary spec. + +## Final Response Requirements + +After creating or updating the artifacts, respond with: + +1. Created or updated spec directory +2. Files created or updated +3. Important repo-based adjustments made +4. Assumptions made +5. Open questions, if any +6. Recommended next manual analysis prompt +7. Explicit statement that no implementation was performed + +Keep the final response concise, but include enough detail for the user to continue immediately. + +## Required Next Manual Analysis Prompt + +Always provide a ready-to-copy prompt like this, adapted to the created spec number and slug: + +```markdown +Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer. + +Analysiere die neu erstellte Spec `-` streng repo-basiert. + +Ziel: +Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar und constitution-konform sind. + +Wichtig: +- Keine Implementierung. +- Keine Codeänderungen. +- Keine Scope-Erweiterung. +- Prüfe nur gegen Repo-Wahrheit. +- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs. +- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor. +- Wenn alles passt, gib eine klare Implementierungsfreigabe. +``` + +## Example Invocation + +User: + +```text +Nimm diesen Spec Candidate und mach daraus spec, plan und tasks in einem Rutsch. Danach mache ich die Analyse manuell. +``` + +Expected behavior: + +1. Inspect constitution, templates, specs, roadmap, and candidate docs. +2. Determine the next valid spec number. +3. Create `spec.md`, `plan.md`, and `tasks.md` in the new spec directory. +4. Keep scope tight. +5. Do not implement. +6. Return the summary and next manual analysis prompt. \ No newline at end of file diff --git a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index fb068711..51b7b184 100644 --- a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -51,6 +51,9 @@ use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderReasonCodes; +use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer; use App\Support\Providers\ProviderVerificationStatus; use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantLifecyclePresentation; @@ -317,7 +320,7 @@ public function content(Schema $schema): Schema Section::make('Tenant') ->schema([ TextInput::make('entra_tenant_id') - ->label('Entra Tenant ID (GUID)') + ->label('Tenant ID (GUID)') ->required() ->placeholder('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx') ->rules(['uuid']) @@ -423,7 +426,8 @@ public function content(Schema $schema): Schema ->required() ->maxLength(255), TextInput::make('entra_tenant_id') - ->label('Directory (tenant) ID') + ->label('Target scope ID') + ->helperText('Provider-owned Microsoft tenant detail for this selected target scope.') ->disabled() ->dehydrated(false), Toggle::make('uses_dedicated_override') @@ -461,6 +465,13 @@ public function content(Schema $schema): Schema ->required(fn (Get $get): bool => $get('connection_mode') === 'new') ->maxLength(255) ->visible(fn (Get $get): bool => $get('connection_mode') === 'new'), + TextInput::make('new_connection.target_scope_id') + ->label('Target scope ID') + ->default(fn (): string => $this->currentManagedTenantRecord()?->tenant_id ?? '') + ->disabled() + ->dehydrated(false) + ->visible(fn (Get $get): bool => $get('connection_mode') === 'new') + ->helperText('The provider connection will point to this tenant target scope.'), TextInput::make('new_connection.connection_type') ->label('Connection type') ->default('Platform connection') @@ -657,7 +668,7 @@ public function content(Schema $schema): Schema UnorderedList::make([ 'Tenant status will be set to Active.', 'Backup, inventory, and compliance operations become available.', - 'The provider connection will be used for all Graph API calls.', + 'The provider connection will be used for provider API calls.', ]), ]), Toggle::make('override_blocked') @@ -1593,6 +1604,7 @@ private function initializeWizardData(): void if ($tenant instanceof Tenant) { $this->data['entra_tenant_id'] ??= (string) $tenant->tenant_id; + $this->data['new_connection']['target_scope_id'] ??= (string) $tenant->tenant_id; $this->data['environment'] ??= (string) ($tenant->environment ?? 'other'); $this->data['name'] ??= (string) $tenant->name; $this->data['primary_domain'] ??= (string) ($tenant->domain ?? ''); @@ -1676,14 +1688,56 @@ private function providerConnectionOptions(): array } return ProviderConnection::query() + ->with('tenant') ->where('workspace_id', (int) $this->workspace->getKey()) ->where('tenant_id', $tenant->getKey()) ->orderByDesc('is_default') ->orderBy('display_name') - ->pluck('display_name', 'id') + ->get() + ->mapWithKeys(fn (ProviderConnection $connection): array => [ + (int) $connection->getKey() => sprintf( + '%s — %s', + (string) $connection->display_name, + $this->providerConnectionTargetScopeSummary($connection), + ), + ]) ->all(); } + private function providerConnectionTargetScopeSummary(ProviderConnection $connection): string + { + try { + return ProviderConnectionSurfaceSummary::forConnection($connection)->targetScopeSummary(); + } catch (InvalidArgumentException) { + return 'Target scope needs review'; + } + } + + /** + * @param array $extra + * @return array + */ + private function providerConnectionTargetScopeAuditMetadata(ProviderConnection $connection, array $extra = []): array + { + try { + return app(ProviderConnectionTargetScopeNormalizer::class)->auditMetadataForConnection($connection, $extra); + } catch (InvalidArgumentException) { + return array_merge([ + 'provider_connection_id' => (int) $connection->getKey(), + 'provider' => (string) $connection->provider, + 'target_scope' => [ + 'provider' => (string) $connection->provider, + 'scope_kind' => ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT, + 'scope_identifier' => (string) $connection->entra_tenant_id, + 'scope_display_name' => (string) ($connection->tenant?->name ?? $connection->display_name ?? $connection->entra_tenant_id), + 'shared_label' => 'Target scope', + 'shared_help_text' => 'The platform scope this provider connection represents.', + ], + 'provider_identity_context' => [], + ], $extra); + } + } + private function verificationStatusLabel(): string { return BadgeCatalog::spec( @@ -2599,12 +2653,11 @@ public function selectProviderConnection(int $providerConnectionId): void workspace: $this->workspace, action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value, context: [ - 'metadata' => [ + 'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [ 'workspace_id' => (int) $this->workspace->getKey(), 'tenant_db_id' => (int) $tenant->getKey(), - 'provider_connection_id' => (int) $connection->getKey(), 'onboarding_session_id' => $this->onboardingSession?->getKey(), - ], + ]), ], actor: $user, status: 'success', @@ -2657,6 +2710,22 @@ public function createProviderConnection(array $data): void abort(422); } + $targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput( + provider: 'microsoft', + scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT, + scopeIdentifier: (string) $tenant->tenant_id, + scopeDisplayName: $displayName, + providerSpecificIdentity: [ + 'microsoft_tenant_id' => (string) $tenant->tenant_id, + ], + ); + + if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) { + throw ValidationException::withMessages([ + 'new_connection.target_scope_id' => $targetScope['message'], + ]); + } + if ($usesDedicatedCredential) { $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED); } @@ -2733,14 +2802,11 @@ public function createProviderConnection(array $data): void tenant: $tenant, action: 'provider_connection.created', context: [ - 'metadata' => [ + 'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [ 'workspace_id' => (int) $this->workspace->getKey(), - 'provider_connection_id' => (int) $connection->getKey(), - 'provider' => (string) $connection->provider, - 'entra_tenant_id' => (string) $connection->entra_tenant_id, 'connection_type' => $connection->connection_type->value, 'source' => 'managed_tenant_onboarding_wizard.create', - ], + ]), ], actorId: $actorId, actorEmail: $actorEmail, @@ -2756,15 +2822,12 @@ public function createProviderConnection(array $data): void tenant: $tenant, action: 'provider_connection.connection_type_changed', context: [ - 'metadata' => [ + 'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [ 'workspace_id' => (int) $this->workspace->getKey(), - 'provider_connection_id' => (int) $connection->getKey(), - 'provider' => (string) $connection->provider, - 'entra_tenant_id' => (string) $connection->entra_tenant_id, 'from_connection_type' => $previousConnectionType->value, 'to_connection_type' => $connection->connection_type->value, 'source' => 'managed_tenant_onboarding_wizard.create', - ], + ]), ], actorId: $actorId, actorEmail: $actorEmail, @@ -4304,15 +4367,12 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId tenant: $this->managedTenant, action: 'provider_connection.connection_type_changed', context: [ - 'metadata' => [ + 'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [ 'workspace_id' => (int) $this->workspace->getKey(), - 'provider_connection_id' => (int) $connection->getKey(), - 'provider' => (string) $connection->provider, - 'entra_tenant_id' => (string) $connection->entra_tenant_id, 'from_connection_type' => $existingType->value, 'to_connection_type' => $targetType->value, 'source' => 'managed_tenant_onboarding_wizard.inline_edit', - ], + ]), ], actorId: (int) $user->getKey(), actorEmail: (string) $user->email, @@ -4328,15 +4388,12 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId tenant: $this->managedTenant, action: 'provider_connection.updated', context: [ - 'metadata' => [ + 'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [ 'workspace_id' => (int) $this->workspace->getKey(), - 'provider_connection_id' => (int) $connection->getKey(), - 'provider' => (string) $connection->provider, - 'entra_tenant_id' => (string) $connection->entra_tenant_id, - 'fields' => $changedFields, + 'fields' => app(ProviderConnectionTargetScopeNormalizer::class)->auditFieldNames($changedFields), 'connection_type' => $targetType->value, 'source' => 'managed_tenant_onboarding_wizard.inline_edit', - ], + ]), ], actorId: (int) $user->getKey(), actorEmail: (string) $user->email, diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php index ee155533..66c07a82 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php @@ -26,6 +26,8 @@ use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderVerificationStatus; +use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer; use App\Support\Rbac\UiEnforcement; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; @@ -50,6 +52,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Str; +use InvalidArgumentException; use UnitEnum; class ProviderConnectionResource extends Resource @@ -484,6 +487,62 @@ private static function verificationStatusLabelFromState(mixed $state): string return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label; } + private static function targetScopeHelpText(): string + { + return 'The platform scope this provider connection represents. For Microsoft, use the tenant directory ID for that scope.'; + } + + private static function targetScopeSummary(?ProviderConnection $record): string + { + if (! $record instanceof ProviderConnection) { + return 'Target scope is set when this connection is saved.'; + } + + try { + return ProviderConnectionSurfaceSummary::forConnection($record)->targetScopeSummary(); + } catch (InvalidArgumentException) { + return 'Target scope needs review'; + } + } + + private static function providerIdentityContext(?ProviderConnection $record): ?string + { + if (! $record instanceof ProviderConnection) { + return null; + } + + try { + return ProviderConnectionSurfaceSummary::forConnection($record)->contextualIdentityLine(); + } catch (InvalidArgumentException) { + return null; + } + } + + /** + * @param array $extra + * @return array + */ + public static function targetScopeAuditMetadata(ProviderConnection $record, array $extra = []): array + { + try { + return app(ProviderConnectionTargetScopeNormalizer::class)->auditMetadataForConnection($record, $extra); + } catch (InvalidArgumentException) { + return array_merge([ + 'provider_connection_id' => (int) $record->getKey(), + 'provider' => (string) $record->provider, + 'target_scope' => [ + 'provider' => (string) $record->provider, + 'scope_kind' => 'tenant', + 'scope_identifier' => (string) $record->entra_tenant_id, + 'scope_display_name' => (string) ($record->tenant?->name ?? $record->display_name ?? $record->entra_tenant_id), + 'shared_label' => 'Target scope', + 'shared_help_text' => static::targetScopeHelpText(), + ], + 'provider_identity_context' => [], + ], $extra); + } + } + public static function form(Schema $schema): Schema { return $schema @@ -496,11 +555,17 @@ public static function form(Schema $schema): Schema ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) ->maxLength(255), TextInput::make('entra_tenant_id') - ->label('Entra tenant ID') + ->label('Target scope ID') ->required() ->maxLength(255) + ->helperText(static::targetScopeHelpText()) + ->validationAttribute('target scope ID') ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) ->rules(['uuid']), + Placeholder::make('target_scope_display') + ->label('Target scope') + ->content(fn (?ProviderConnection $record): string => static::targetScopeSummary($record)) + ->visible(fn (?ProviderConnection $record): bool => $record instanceof ProviderConnection), Placeholder::make('connection_type_display') ->label('Connection type') ->content(fn (?ProviderConnection $record): string => static::providerConnectionTypeLabel($record)), @@ -563,8 +628,9 @@ public static function infolist(Schema $schema): Schema ->label('Display name'), Infolists\Components\TextEntry::make('provider') ->label('Provider'), - Infolists\Components\TextEntry::make('entra_tenant_id') - ->label('Entra tenant ID') + Infolists\Components\TextEntry::make('target_scope') + ->label('Target scope') + ->state(fn (ProviderConnection $record): string => static::targetScopeSummary($record)) ->copyable(), Infolists\Components\TextEntry::make('connection_type') ->label('Connection type') @@ -614,6 +680,11 @@ public static function infolist(Schema $schema): Schema ->label('Migration review') ->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record)) ->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)), + Infolists\Components\TextEntry::make('provider_identity_context') + ->label('Provider identity details') + ->state(fn (ProviderConnection $record): ?string => static::providerIdentityContext($record)) + ->placeholder('n/a') + ->columnSpanFull(), Infolists\Components\TextEntry::make('last_error_reason_code') ->label('Last error reason') ->placeholder('n/a'), @@ -671,9 +742,15 @@ public static function table(Table $table): Table return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'); }), Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable()->sortable(), - Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(isToggledHiddenByDefault: true), - Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true), - Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(), + Tables\Columns\TextColumn::make('provider') + ->label('Provider') + ->formatStateUsing(fn (?string $state): string => Str::headline((string) $state)), + Tables\Columns\TextColumn::make('target_scope') + ->label('Target scope') + ->state(fn (ProviderConnection $record): string => static::targetScopeSummary($record)) + ->copyable(), + Tables\Columns\TextColumn::make('entra_tenant_id')->label('Microsoft tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean()->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('connection_type') ->label('Connection type') ->badge() @@ -949,10 +1026,7 @@ public static function makeSetDefaultAction(): Actions\Action tenant: $tenant, action: 'provider_connection.default_set', context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, - ], + 'metadata' => static::targetScopeAuditMetadata($record), ], actorId: $actorId, actorEmail: $actorEmail, @@ -1014,15 +1088,12 @@ public static function makeEnableDedicatedOverrideAction(string $source, ?string tenant: $tenant, action: 'provider_connection.connection_type_changed', context: [ - 'metadata' => [ - 'provider_connection_id' => (int) $record->getKey(), - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, + 'metadata' => static::targetScopeAuditMetadata($record, [ 'from_connection_type' => ProviderConnectionType::Platform->value, 'to_connection_type' => ProviderConnectionType::Dedicated->value, 'client_id' => (string) $data['client_id'], 'source' => $source, - ], + ]), ], actorId: $actorId, actorEmail: $actorEmail, @@ -1161,14 +1232,11 @@ public static function makeRevertToPlatformAction(string $source, ?string $modal tenant: $tenant, action: 'provider_connection.connection_type_changed', context: [ - 'metadata' => [ - 'provider_connection_id' => (int) $record->getKey(), - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, + 'metadata' => static::targetScopeAuditMetadata($record, [ 'from_connection_type' => ProviderConnectionType::Dedicated->value, 'to_connection_type' => ProviderConnectionType::Platform->value, 'source' => $source, - ], + ]), ], actorId: $actorId, actorEmail: $actorEmail, @@ -1233,14 +1301,12 @@ public static function makeEnableConnectionAction(): Actions\Action tenant: $tenant, action: 'provider_connection.enabled', context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, + 'metadata' => static::targetScopeAuditMetadata($record, [ 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled', 'to_lifecycle' => 'enabled', 'verification_status' => $verificationStatus->value, 'credentials_present' => $hadCredentials, - ], + ]), ], actorId: $actorId, actorEmail: $actorEmail, @@ -1302,12 +1368,10 @@ public static function makeDisableConnectionAction(): Actions\Action tenant: $tenant, action: 'provider_connection.disabled', context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, + 'metadata' => static::targetScopeAuditMetadata($record, [ 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled', 'to_lifecycle' => 'disabled', - ], + ]), ], actorId: $actorId, actorEmail: $actorEmail, diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php index f9b3c94c..67ff1bdc 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php @@ -9,9 +9,12 @@ use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderReasonCodes; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer; use App\Support\Providers\ProviderVerificationStatus; use Filament\Notifications\Notification; use Filament\Resources\Pages\CreateRecord; +use Illuminate\Validation\ValidationException; class CreateProviderConnection extends CreateRecord { @@ -28,6 +31,21 @@ protected function mutateFormDataBeforeCreate(array $data): array } $this->shouldMakeDefault = (bool) ($data['is_default'] ?? false); + $targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput( + provider: 'microsoft', + scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT, + scopeIdentifier: (string) ($data['entra_tenant_id'] ?? ''), + scopeDisplayName: (string) ($data['display_name'] ?? ''), + providerSpecificIdentity: [ + 'microsoft_tenant_id' => (string) ($data['entra_tenant_id'] ?? ''), + ], + ); + + if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) { + throw ValidationException::withMessages([ + 'entra_tenant_id' => $targetScope['message'], + ]); + } return [ 'workspace_id' => (int) $tenant->workspace_id, @@ -70,11 +88,9 @@ protected function afterCreate(): void tenant: $tenant, action: 'provider_connection.created', context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, + 'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [ 'connection_type' => $record->connection_type->value, - ], + ]), ], actorId: $actorId, actorEmail: $actorEmail, diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php index c79956ce..4a0734f8 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php @@ -19,6 +19,8 @@ use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Providers\ProviderConnectionType; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer; use App\Support\Rbac\UiEnforcement; use Filament\Actions; use Filament\Actions\Action; @@ -26,6 +28,7 @@ use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; use Illuminate\Database\Eloquent\Model; +use Illuminate\Validation\ValidationException; class EditProviderConnection extends EditRecord { @@ -77,6 +80,22 @@ protected function mutateFormDataBeforeSave(array $data): array $this->shouldMakeDefault = (bool) ($data['is_default'] ?? false); unset($data['is_default']); + $targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput( + provider: 'microsoft', + scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT, + scopeIdentifier: (string) ($data['entra_tenant_id'] ?? ''), + scopeDisplayName: (string) ($data['display_name'] ?? ''), + providerSpecificIdentity: [ + 'microsoft_tenant_id' => (string) ($data['entra_tenant_id'] ?? ''), + ], + ); + + if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) { + throw ValidationException::withMessages([ + 'entra_tenant_id' => $targetScope['message'], + ]); + } + return $data; } @@ -119,11 +138,9 @@ protected function afterSave(): void tenant: $tenant, action: 'provider_connection.updated', context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, - 'fields' => $changedFields, - ], + 'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [ + 'fields' => app(ProviderConnectionTargetScopeNormalizer::class)->auditFieldNames($changedFields), + ]), ], actorId: $actorId, actorEmail: $actorEmail, @@ -139,10 +156,7 @@ protected function afterSave(): void tenant: $tenant, action: 'provider_connection.default_set', context: [ - 'metadata' => [ - 'provider' => $record->provider, - 'entra_tenant_id' => $record->entra_tenant_id, - ], + 'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record), ], actorId: $actorId, actorEmail: $actorEmail, diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php index 333d2e64..03415391 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php @@ -237,12 +237,12 @@ private function resolveTenantForCreateAction(): ?Tenant public function getTableEmptyStateHeading(): ?string { - return 'No Microsoft connections found'; + return 'No provider connections found'; } public function getTableEmptyStateDescription(): ?string { - return 'Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.'; + return 'Start with a platform-managed provider connection. Dedicated overrides are handled separately with stronger authorization.'; } public function getTableEmptyStateActions(): array diff --git a/apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php b/apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php index a6fc6ab6..eb2614c6 100644 --- a/apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php +++ b/apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php @@ -452,6 +452,11 @@ private function logVerificationResult( 'verification_status' => $connection->verification_status?->value ?? $connection->verification_status, 'credential_source' => $identity->credentialSource, 'effective_client_id' => $identity->effectiveClientId, + 'target_scope' => $identity->targetScope?->toArray(), + 'provider_identity_context' => array_map( + static fn ($detail): array => $detail->toArray(), + $identity->contextualIdentityDetails, + ), 'reason_code' => $reasonCode, 'operation_run_id' => (int) $run->getKey(), 'previous_consent_status' => $previousConsentStatus, diff --git a/apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php b/apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php index abf519d3..5a696eda 100644 --- a/apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php +++ b/apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php @@ -4,11 +4,19 @@ use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderReasonCodes; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor; +use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata; final class PlatformProviderIdentityResolver { - public function resolve(string $tenantContext): ProviderIdentityResolution - { + /** + * @param list $contextualIdentityDetails + */ + public function resolve( + string $tenantContext, + ?ProviderConnectionTargetScopeDescriptor $targetScope = null, + array $contextualIdentityDetails = [], + ): ProviderIdentityResolution { $targetTenant = trim($tenantContext); $clientId = trim((string) config('graph.client_id')); $clientSecret = trim((string) config('graph.client_secret')); @@ -22,6 +30,8 @@ public function resolve(string $tenantContext): ProviderIdentityResolution credentialSource: 'platform_config', reasonCode: ProviderReasonCodes::ProviderConnectionInvalid, message: 'Provider connection is missing target tenant scope.', + targetScope: $targetScope, + contextualIdentityDetails: $contextualIdentityDetails, ); } @@ -32,6 +42,8 @@ public function resolve(string $tenantContext): ProviderIdentityResolution credentialSource: 'platform_config', reasonCode: ProviderReasonCodes::PlatformIdentityMissing, message: 'Platform app identity is not configured.', + targetScope: $targetScope, + contextualIdentityDetails: $contextualIdentityDetails, ); } @@ -42,6 +54,8 @@ public function resolve(string $tenantContext): ProviderIdentityResolution credentialSource: 'platform_config', reasonCode: ProviderReasonCodes::PlatformIdentityIncomplete, message: 'Platform app identity is incomplete.', + targetScope: $targetScope, + contextualIdentityDetails: $contextualIdentityDetails, ); } @@ -53,6 +67,13 @@ public function resolve(string $tenantContext): ProviderIdentityResolution clientSecret: $clientSecret, authorityTenant: $authorityTenant !== '' ? $authorityTenant : 'organizations', redirectUri: $redirectUri, + targetScope: $targetScope, + contextualIdentityDetails: $contextualIdentityDetails !== [] + ? array_values(array_merge($contextualIdentityDetails, array_filter([ + ProviderIdentityContextMetadata::authorityTenant($authorityTenant !== '' ? $authorityTenant : 'organizations'), + ProviderIdentityContextMetadata::redirectUri($redirectUri), + ]))) + : [], ); } } diff --git a/apps/platform/app/Services/Providers/ProviderConnectionMutationService.php b/apps/platform/app/Services/Providers/ProviderConnectionMutationService.php index 8b4b6f10..65e91bc1 100644 --- a/apps/platform/app/Services/Providers/ProviderConnectionMutationService.php +++ b/apps/platform/app/Services/Providers/ProviderConnectionMutationService.php @@ -26,7 +26,7 @@ public function enableDedicatedOverride( $clientSecret = trim($clientSecret); if ($clientId === '' || $clientSecret === '') { - throw new InvalidArgumentException('Dedicated client_id and client_secret are required.'); + throw new InvalidArgumentException('Dedicated app (client) ID and client secret are required.'); } return DB::transaction(function () use ($connection, $clientId, $clientSecret): ProviderConnection { diff --git a/apps/platform/app/Services/Providers/ProviderConnectionResolution.php b/apps/platform/app/Services/Providers/ProviderConnectionResolution.php index fcca20bc..68d864a8 100644 --- a/apps/platform/app/Services/Providers/ProviderConnectionResolution.php +++ b/apps/platform/app/Services/Providers/ProviderConnectionResolution.php @@ -4,25 +4,38 @@ use App\Models\ProviderConnection; use App\Support\Providers\ProviderReasonCodes; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer; +use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata; final class ProviderConnectionResolution { + /** + * @param list $contextualIdentityDetails + */ private function __construct( public readonly bool $resolved, public readonly ?ProviderConnection $connection, public readonly ?string $reasonCode, public readonly ?string $extensionReasonCode, public readonly ?string $message, + public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope, + public readonly array $contextualIdentityDetails, ) {} public static function resolved(ProviderConnection $connection): self { + /** @var ProviderConnectionTargetScopeNormalizer $normalizer */ + $normalizer = app(ProviderConnectionTargetScopeNormalizer::class); + return new self( resolved: true, connection: $connection, reasonCode: null, extensionReasonCode: null, message: null, + targetScope: $normalizer->descriptorForConnection($connection), + contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection), ); } @@ -32,12 +45,29 @@ public static function blocked( ?string $extensionReasonCode = null, ?ProviderConnection $connection = null, ): self { + /** @var ProviderConnectionTargetScopeNormalizer $normalizer */ + $normalizer = app(ProviderConnectionTargetScopeNormalizer::class); + $targetScope = null; + $contextualIdentityDetails = []; + + if ($connection instanceof ProviderConnection) { + $normalization = $normalizer->normalizeConnection($connection); + $descriptor = $normalization['target_scope'] ?? null; + + if ($descriptor instanceof ProviderConnectionTargetScopeDescriptor) { + $targetScope = $descriptor; + $contextualIdentityDetails = $normalizer->contextualIdentityDetailsForConnection($connection); + } + } + return new self( resolved: false, connection: $connection, reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError, extensionReasonCode: $extensionReasonCode, message: $message, + targetScope: $targetScope, + contextualIdentityDetails: $contextualIdentityDetails, ); } diff --git a/apps/platform/app/Services/Providers/ProviderConnectionResolver.php b/apps/platform/app/Services/Providers/ProviderConnectionResolver.php index 7409f15a..84891935 100644 --- a/apps/platform/app/Services/Providers/ProviderConnectionResolver.php +++ b/apps/platform/app/Services/Providers/ProviderConnectionResolver.php @@ -6,11 +6,13 @@ use App\Models\Tenant; use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderReasonCodes; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer; final class ProviderConnectionResolver { public function __construct( private readonly ProviderIdentityResolver $identityResolver, + private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer, ) {} public function resolveDefault(Tenant $tenant, string $provider): ProviderConnectionResolution @@ -63,11 +65,19 @@ public function validateConnection(Tenant $tenant, string $provider, ProviderCon ); } - if ($connection->entra_tenant_id === null || trim((string) $connection->entra_tenant_id) === '') { + $targetScope = $this->targetScopeNormalizer->normalizeConnection($connection); + + if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) { + $failureCode = $targetScope['failure_code'] ?? ProviderConnectionTargetScopeNormalizer::FAILURE_MISSING_PROVIDER_CONTEXT; + return ProviderConnectionResolution::blocked( - ProviderReasonCodes::ProviderConnectionInvalid, - 'Provider connection is missing target tenant scope.', - 'ext.connection_tenant_missing', + $failureCode === ProviderConnectionTargetScopeNormalizer::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION + ? ProviderReasonCodes::ProviderBindingUnsupported + : ProviderReasonCodes::ProviderConnectionInvalid, + $targetScope['message'] ?? 'Provider connection target scope is invalid.', + $failureCode === ProviderConnectionTargetScopeNormalizer::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION + ? 'ext.connection_scope_unsupported' + : 'ext.connection_scope_missing', $connection, ); } diff --git a/apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php b/apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php index c1eeec88..ebbe4419 100644 --- a/apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php +++ b/apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php @@ -6,10 +6,16 @@ use App\Services\Providers\Contracts\HealthResult; use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderReasonCodes; +use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary; use App\Support\Providers\ProviderVerificationStatus; final class ProviderConnectionStateProjector { + public function surfaceSummary(ProviderConnection $connection): ProviderConnectionSurfaceSummary + { + return ProviderConnectionSurfaceSummary::forConnection($connection); + } + /** * @return array{ * consent_status: ProviderConsentStatus, diff --git a/apps/platform/app/Services/Providers/ProviderIdentityResolution.php b/apps/platform/app/Services/Providers/ProviderIdentityResolution.php index 33619e1a..77a2475d 100644 --- a/apps/platform/app/Services/Providers/ProviderIdentityResolution.php +++ b/apps/platform/app/Services/Providers/ProviderIdentityResolution.php @@ -4,9 +4,14 @@ use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderReasonCodes; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor; +use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata; final class ProviderIdentityResolution { + /** + * @param list $contextualIdentityDetails + */ private function __construct( public readonly bool $resolved, public readonly ProviderConnectionType $connectionType, @@ -18,6 +23,8 @@ private function __construct( public readonly ?string $redirectUri, public readonly ?string $reasonCode, public readonly ?string $message, + public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope, + public readonly array $contextualIdentityDetails, ) {} public static function resolved( @@ -28,6 +35,8 @@ public static function resolved( ?string $clientSecret, ?string $authorityTenant, ?string $redirectUri, + ?ProviderConnectionTargetScopeDescriptor $targetScope = null, + array $contextualIdentityDetails = [], ): self { return new self( resolved: true, @@ -40,6 +49,10 @@ public static function resolved( redirectUri: $redirectUri, reasonCode: null, message: null, + targetScope: $targetScope ?? self::targetScopeFromContext($tenantContext), + contextualIdentityDetails: $contextualIdentityDetails !== [] + ? $contextualIdentityDetails + : self::contextualIdentityDetails($tenantContext, $authorityTenant, $redirectUri), ); } @@ -49,6 +62,8 @@ public static function blocked( string $credentialSource, string $reasonCode, ?string $message = null, + ?ProviderConnectionTargetScopeDescriptor $targetScope = null, + array $contextualIdentityDetails = [], ): self { return new self( resolved: false, @@ -61,6 +76,10 @@ public static function blocked( redirectUri: null, reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError, message: $message, + targetScope: $targetScope ?? (trim($tenantContext) !== '' ? self::targetScopeFromContext($tenantContext) : null), + contextualIdentityDetails: $contextualIdentityDetails !== [] + ? $contextualIdentityDetails + : self::contextualIdentityDetails($tenantContext), ); } @@ -68,4 +87,36 @@ public function effectiveReasonCode(): string { return $this->reasonCode ?? ProviderReasonCodes::UnknownError; } + + private static function targetScopeFromContext(string $tenantContext): ProviderConnectionTargetScopeDescriptor + { + $identifier = trim($tenantContext) !== '' ? trim($tenantContext) : 'organizations'; + + return ProviderConnectionTargetScopeDescriptor::fromInput( + provider: 'microsoft', + scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT, + scopeIdentifier: $identifier, + scopeDisplayName: $identifier, + ); + } + + /** + * @return list + */ + private static function contextualIdentityDetails( + string $tenantContext, + ?string $authorityTenant = null, + ?string $redirectUri = null, + ): array { + $details = [ + ProviderIdentityContextMetadata::microsoftTenantId($tenantContext), + ProviderIdentityContextMetadata::authorityTenant($authorityTenant), + ProviderIdentityContextMetadata::redirectUri($redirectUri), + ]; + + return array_values(array_filter( + $details, + static fn (?ProviderIdentityContextMetadata $detail): bool => $detail instanceof ProviderIdentityContextMetadata, + )); + } } diff --git a/apps/platform/app/Services/Providers/ProviderIdentityResolver.php b/apps/platform/app/Services/Providers/ProviderIdentityResolver.php index 2bbab381..a43dec4f 100644 --- a/apps/platform/app/Services/Providers/ProviderIdentityResolver.php +++ b/apps/platform/app/Services/Providers/ProviderIdentityResolver.php @@ -7,6 +7,8 @@ use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderCredentialSource; use App\Support\Providers\ProviderReasonCodes; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor; +use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer; use InvalidArgumentException; use RuntimeException; @@ -15,12 +17,16 @@ final class ProviderIdentityResolver public function __construct( private readonly PlatformProviderIdentityResolver $platformResolver, private readonly CredentialManager $credentials, + private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer, ) {} public function resolve(ProviderConnection $connection): ProviderIdentityResolution { $tenantContext = trim((string) $connection->entra_tenant_id); $connectionType = $this->resolveConnectionType($connection); + $targetScopeResult = $this->targetScopeNormalizer->normalizeConnection($connection); + $targetScope = $targetScopeResult['target_scope'] ?? null; + $contextualIdentityDetails = $this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection); if ($connectionType === null) { return ProviderIdentityResolution::blocked( @@ -29,16 +35,20 @@ public function resolve(ProviderConnection $connection): ProviderIdentityResolut credentialSource: 'unknown', reasonCode: ProviderReasonCodes::ProviderConnectionTypeInvalid, message: 'Provider connection type is invalid.', + targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null, + contextualIdentityDetails: $contextualIdentityDetails, ); } - if ($tenantContext === '') { + if ($targetScopeResult['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) { return ProviderIdentityResolution::blocked( connectionType: $connectionType, tenantContext: 'organizations', credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::DedicatedManual->value, reasonCode: ProviderReasonCodes::ProviderConnectionInvalid, - message: 'Provider connection is missing target tenant scope.', + message: $targetScopeResult['message'] ?? 'Provider connection target scope is invalid.', + targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null, + contextualIdentityDetails: $contextualIdentityDetails, ); } @@ -49,14 +59,25 @@ public function resolve(ProviderConnection $connection): ProviderIdentityResolut credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::LegacyMigrated->value, reasonCode: ProviderReasonCodes::ProviderConnectionReviewRequired, message: 'Provider connection requires migration review before use.', + targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null, + contextualIdentityDetails: $contextualIdentityDetails, ); } if ($connectionType === ProviderConnectionType::Platform) { - return $this->platformResolver->resolve($tenantContext); + return $this->platformResolver->resolve( + tenantContext: $tenantContext, + targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null, + contextualIdentityDetails: $contextualIdentityDetails, + ); } - return $this->resolveDedicatedIdentity($connection, $tenantContext); + return $this->resolveDedicatedIdentity( + connection: $connection, + tenantContext: $tenantContext, + targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null, + contextualIdentityDetails: $contextualIdentityDetails, + ); } private function resolveConnectionType(ProviderConnection $connection): ?ProviderConnectionType @@ -77,6 +98,8 @@ private function resolveConnectionType(ProviderConnection $connection): ?Provide private function resolveDedicatedIdentity( ProviderConnection $connection, string $tenantContext, + ?ProviderConnectionTargetScopeDescriptor $targetScope = null, + array $contextualIdentityDetails = [], ): ProviderIdentityResolution { try { $credentials = $this->credentials->getClientCredentials($connection); @@ -89,6 +112,8 @@ private function resolveDedicatedIdentity( ? ProviderReasonCodes::DedicatedCredentialInvalid : ProviderReasonCodes::DedicatedCredentialMissing, message: $exception->getMessage(), + targetScope: $targetScope, + contextualIdentityDetails: $contextualIdentityDetails, ); } @@ -100,6 +125,8 @@ private function resolveDedicatedIdentity( clientSecret: $credentials['client_secret'], authorityTenant: $tenantContext, redirectUri: trim((string) route('admin.consent.callback')), + targetScope: $targetScope, + contextualIdentityDetails: $contextualIdentityDetails, ); } diff --git a/apps/platform/app/Services/Verification/StartVerification.php b/apps/platform/app/Services/Verification/StartVerification.php index 20829f79..6a13bfd9 100644 --- a/apps/platform/app/Services/Verification/StartVerification.php +++ b/apps/platform/app/Services/Verification/StartVerification.php @@ -119,6 +119,11 @@ public function providerConnectionCheckUsingConnection( 'connection_type' => $identity->connectionType->value, 'credential_source' => $identity->credentialSource, 'effective_client_id' => $identity->effectiveClientId, + 'target_scope' => $identity->targetScope?->toArray(), + 'provider_identity_context' => array_map( + static fn ($detail): array => $detail->toArray(), + $identity->contextualIdentityDetails, + ), ], ]), ); diff --git a/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php b/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php new file mode 100644 index 00000000..12cb468c --- /dev/null +++ b/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php @@ -0,0 +1,119 @@ + $contextualIdentityDetails + */ + public function __construct( + public readonly string $provider, + public readonly ProviderConnectionTargetScopeDescriptor $targetScope, + public readonly string $consentState, + public readonly string $verificationState, + public readonly string $readinessSummary, + public readonly array $contextualIdentityDetails = [], + ) {} + + public static function forConnection(ProviderConnection $connection): self + { + /** @var ProviderConnectionTargetScopeNormalizer $normalizer */ + $normalizer = app(ProviderConnectionTargetScopeNormalizer::class); + $targetScope = $normalizer->descriptorForConnection($connection); + $consentState = self::stateValue($connection->consent_status); + $verificationState = self::stateValue($connection->verification_status); + + return new self( + provider: trim((string) $connection->provider), + targetScope: $targetScope, + consentState: $consentState, + verificationState: $verificationState, + readinessSummary: self::readinessSummary( + isEnabled: (bool) $connection->is_enabled, + consentState: $consentState, + verificationState: $verificationState, + ), + contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection), + ); + } + + public function targetScopeSummary(): string + { + return $this->targetScope->summary(); + } + + public function contextualIdentityLine(): ?string + { + if ($this->contextualIdentityDetails === []) { + return null; + } + + return collect($this->contextualIdentityDetails) + ->map(fn (ProviderIdentityContextMetadata $detail): string => sprintf('%s: %s', $detail->detailLabel, $detail->detailValue)) + ->implode("\n"); + } + + /** + * @return array{ + * provider: string, + * target_scope: array, + * consent_state: string, + * verification_state: string, + * readiness_summary: string, + * contextual_identity_details: list> + * } + */ + public function toArray(): array + { + return [ + 'provider' => $this->provider, + 'target_scope' => $this->targetScope->toArray(), + 'consent_state' => $this->consentState, + 'verification_state' => $this->verificationState, + 'readiness_summary' => $this->readinessSummary, + 'contextual_identity_details' => array_map( + static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(), + $this->contextualIdentityDetails, + ), + ]; + } + + private static function stateValue(mixed $state): string + { + if ($state instanceof ProviderConsentStatus || $state instanceof ProviderVerificationStatus) { + return $state->value; + } + + return trim((string) $state); + } + + private static function readinessSummary(bool $isEnabled, string $consentState, string $verificationState): string + { + if (! $isEnabled) { + return 'Disabled'; + } + + if ($consentState !== ProviderConsentStatus::Granted->value) { + return sprintf( + 'Consent %s', + strtolower(BadgeRenderer::spec(BadgeDomain::ProviderConsentStatus, $consentState)->label), + ); + } + + return match ($verificationState) { + ProviderVerificationStatus::Healthy->value => 'Ready', + ProviderVerificationStatus::Degraded->value => 'Ready with warnings', + ProviderVerificationStatus::Blocked->value => 'Verification blocked', + ProviderVerificationStatus::Error->value => 'Verification failed', + ProviderVerificationStatus::Pending->value => 'Verification pending', + default => 'Verification not run', + }; + } +} diff --git a/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php b/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php new file mode 100644 index 00000000..d48b9663 --- /dev/null +++ b/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php @@ -0,0 +1,103 @@ +validate(); + } + + public static function fromConnection(ProviderConnection $connection): self + { + $tenantName = is_string($connection->tenant?->name) && trim($connection->tenant->name) !== '' + ? trim($connection->tenant->name) + : trim((string) $connection->display_name); + + return new self( + provider: trim((string) $connection->provider), + scopeKind: self::SCOPE_KIND_TENANT, + scopeIdentifier: trim((string) $connection->entra_tenant_id), + scopeDisplayName: $tenantName !== '' ? $tenantName : trim((string) $connection->entra_tenant_id), + ); + } + + public static function fromInput( + string $provider, + string $scopeKind, + string $scopeIdentifier, + ?string $scopeDisplayName = null, + ): self { + $scopeIdentifier = trim($scopeIdentifier); + $displayName = trim((string) $scopeDisplayName); + + return new self( + provider: trim($provider), + scopeKind: trim($scopeKind), + scopeIdentifier: $scopeIdentifier, + scopeDisplayName: $displayName !== '' ? $displayName : $scopeIdentifier, + ); + } + + public function summary(): string + { + if ($this->scopeDisplayName !== '' && $this->scopeDisplayName !== $this->scopeIdentifier) { + return sprintf('%s (%s)', $this->scopeDisplayName, $this->scopeIdentifier); + } + + return $this->scopeIdentifier; + } + + /** + * @return array{ + * provider: string, + * scope_kind: string, + * scope_identifier: string, + * scope_display_name: string, + * shared_label: string, + * shared_help_text: string + * } + */ + public function toArray(): array + { + return [ + 'provider' => $this->provider, + 'scope_kind' => $this->scopeKind, + 'scope_identifier' => $this->scopeIdentifier, + 'scope_display_name' => $this->scopeDisplayName, + 'shared_label' => $this->sharedLabel, + 'shared_help_text' => $this->sharedHelpText, + ]; + } + + private function validate(): void + { + if ($this->provider === '') { + throw new InvalidArgumentException('Provider is required for target-scope descriptors.'); + } + + if ($this->scopeKind !== self::SCOPE_KIND_TENANT) { + throw new InvalidArgumentException('Unsupported provider connection target-scope kind.'); + } + + if ($this->scopeIdentifier === '') { + throw new InvalidArgumentException('Target scope identifier is required.'); + } + + if ($this->scopeDisplayName === '') { + throw new InvalidArgumentException('Target scope display name is required.'); + } + } +} diff --git a/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php b/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php new file mode 100644 index 00000000..ba27a727 --- /dev/null +++ b/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php @@ -0,0 +1,207 @@ + $providerSpecificIdentity + * @return array{ + * status: string, + * provider: string, + * scope_kind: string, + * target_scope?: ProviderConnectionTargetScopeDescriptor, + * contextual_identity_details?: list, + * preview_summary?: ?ProviderConnectionSurfaceSummary, + * failure_code: string, + * message: string + * } + */ + public function normalizeInput( + string $provider, + string $scopeKind, + string $scopeIdentifier, + ?string $scopeDisplayName = null, + array $providerSpecificIdentity = [], + ): array { + $provider = trim($provider); + $scopeKind = trim($scopeKind); + $scopeIdentifier = trim($scopeIdentifier); + + if ($provider !== 'microsoft' || $scopeKind !== ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT) { + return $this->blocked( + provider: $provider, + scopeKind: $scopeKind, + failureCode: self::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION, + message: 'This provider and target-scope combination is not supported.', + ); + } + + if ($scopeIdentifier === '') { + return $this->blocked( + provider: $provider, + scopeKind: $scopeKind, + failureCode: self::FAILURE_MISSING_PROVIDER_CONTEXT, + message: 'A target scope identifier is required for this provider connection.', + ); + } + + $descriptor = ProviderConnectionTargetScopeDescriptor::fromInput( + provider: $provider, + scopeKind: $scopeKind, + scopeIdentifier: $scopeIdentifier, + scopeDisplayName: $scopeDisplayName, + ); + + return [ + 'status' => self::STATUS_NORMALIZED, + 'provider' => $provider, + 'scope_kind' => $scopeKind, + 'target_scope' => $descriptor, + 'contextual_identity_details' => $this->contextualIdentityDetails( + provider: $provider, + scopeIdentifier: $scopeIdentifier, + providerSpecificIdentity: $providerSpecificIdentity, + ), + 'preview_summary' => null, + 'failure_code' => self::FAILURE_NONE, + 'message' => 'Target scope normalized.', + ]; + } + + /** + * @return array{ + * status: string, + * provider: string, + * scope_kind: string, + * target_scope?: ProviderConnectionTargetScopeDescriptor, + * contextual_identity_details?: list, + * preview_summary?: ?ProviderConnectionSurfaceSummary, + * failure_code: string, + * message: string + * } + */ + public function normalizeConnection(ProviderConnection $connection): array + { + return $this->normalizeInput( + provider: trim((string) $connection->provider), + scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT, + scopeIdentifier: trim((string) $connection->entra_tenant_id), + scopeDisplayName: $connection->tenant?->name ?? $connection->display_name, + providerSpecificIdentity: [ + 'microsoft_tenant_id' => trim((string) $connection->entra_tenant_id), + ], + ); + } + + public function descriptorForConnection(ProviderConnection $connection): ProviderConnectionTargetScopeDescriptor + { + $result = $this->normalizeConnection($connection); + $descriptor = $result['target_scope'] ?? null; + + if (! $descriptor instanceof ProviderConnectionTargetScopeDescriptor) { + throw new InvalidArgumentException($result['message']); + } + + return $descriptor; + } + + /** + * @return list + */ + public function contextualIdentityDetailsForConnection(ProviderConnection $connection): array + { + return $this->contextualIdentityDetails( + provider: trim((string) $connection->provider), + scopeIdentifier: trim((string) $connection->entra_tenant_id), + providerSpecificIdentity: [ + 'microsoft_tenant_id' => trim((string) $connection->entra_tenant_id), + ], + ); + } + + /** + * @return array + */ + public function auditMetadataForConnection(ProviderConnection $connection, array $extra = []): array + { + $summary = ProviderConnectionSurfaceSummary::forConnection($connection); + + return array_merge([ + 'provider_connection_id' => (int) $connection->getKey(), + 'provider' => (string) $connection->provider, + 'target_scope' => $summary->targetScope->toArray(), + 'provider_identity_context' => array_map( + static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(), + $summary->contextualIdentityDetails, + ), + ], $extra); + } + + /** + * @param list $fields + * @return list + */ + public function auditFieldNames(array $fields): array + { + return array_values(array_map( + static fn (string $field): string => $field === 'entra_tenant_id' ? 'target_scope_identifier' : $field, + $fields, + )); + } + + /** + * @param array $providerSpecificIdentity + * @return list + */ + private function contextualIdentityDetails(string $provider, string $scopeIdentifier, array $providerSpecificIdentity = []): array + { + if ($provider !== 'microsoft') { + return []; + } + + $details = [ + ProviderIdentityContextMetadata::microsoftTenantId( + $providerSpecificIdentity['microsoft_tenant_id'] ?? $scopeIdentifier, + ), + ]; + + return array_values(array_filter( + $details, + static fn (?ProviderIdentityContextMetadata $detail): bool => $detail instanceof ProviderIdentityContextMetadata, + )); + } + + /** + * @return array{ + * status: string, + * provider: string, + * scope_kind: string, + * failure_code: string, + * message: string + * } + */ + private function blocked(string $provider, string $scopeKind, string $failureCode, string $message): array + { + return [ + 'status' => self::STATUS_BLOCKED, + 'provider' => $provider, + 'scope_kind' => $scopeKind, + 'failure_code' => $failureCode, + 'message' => $message, + ]; + } +} diff --git a/apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php b/apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php new file mode 100644 index 00000000..30738ca5 --- /dev/null +++ b/apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php @@ -0,0 +1,91 @@ + $this->provider, + 'detail_key' => $this->detailKey, + 'detail_label' => $this->detailLabel, + 'detail_value' => $this->detailValue, + 'visibility' => $this->visibility, + ]; + } +} diff --git a/apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php b/apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php index e0572ba3..d683b92a 100644 --- a/apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php +++ b/apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php @@ -2,10 +2,14 @@ declare(strict_types=1); +use App\Filament\Resources\ProviderConnectionResource\Pages\CreateProviderConnection; use App\Models\AuditLog; +use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; +use Livewire\Livewire; uses(RefreshDatabase::class); @@ -63,3 +67,55 @@ ]); } }); + +it('records provider connection create audits with neutral target-scope metadata', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(CreateProviderConnection::class) + ->fillForm([ + 'display_name' => 'Audit target scope connection', + 'entra_tenant_id' => '88888888-8888-8888-8888-888888888888', + 'is_default' => true, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $connection = ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('display_name', 'Audit target scope connection') + ->firstOrFail(); + + $log = AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('action', 'provider_connection.created') + ->where('resource_id', (string) $connection->getKey()) + ->firstOrFail(); + + $metadata = is_array($log->metadata) ? $log->metadata : []; + + expect($metadata)->toHaveKeys([ + 'provider_connection_id', + 'provider', + 'target_scope', + 'provider_identity_context', + 'connection_type', + ]) + ->and($metadata)->not->toHaveKey('entra_tenant_id') + ->and($metadata['target_scope'])->toMatchArray([ + 'provider' => 'microsoft', + 'scope_kind' => 'tenant', + 'scope_identifier' => '88888888-8888-8888-8888-888888888888', + 'shared_label' => 'Target scope', + ]) + ->and($metadata['provider_identity_context'][0] ?? [])->toMatchArray([ + 'provider' => 'microsoft', + 'detail_key' => 'microsoft_tenant_id', + 'detail_label' => 'Microsoft tenant ID', + 'detail_value' => '88888888-8888-8888-8888-888888888888', + ]); +}); diff --git a/apps/platform/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php b/apps/platform/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php index 51cd2fdd..9760c7bb 100644 --- a/apps/platform/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php +++ b/apps/platform/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php @@ -58,15 +58,18 @@ ->all(); expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource()); - expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found'); + expect($table->getEmptyStateHeading())->toBe('No provider connections found'); expect($table->getColumn('display_name')?->isSearchable())->toBeTrue(); expect($table->getColumn('display_name')?->isSortable())->toBeTrue(); - expect($visibleColumnNames)->toContain('is_enabled', 'consent_status', 'verification_status'); + expect($visibleColumnNames)->toContain('provider', 'target_scope', 'is_enabled', 'consent_status', 'verification_status'); expect($visibleColumnNames)->not->toContain('status'); expect($visibleColumnNames)->not->toContain('health_status'); + expect($visibleColumnNames)->not->toContain('entra_tenant_id'); expect($table->getColumn('status'))->toBeNull(); expect($table->getColumn('health_status'))->toBeNull(); - expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue(); + expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeFalse(); + expect($table->getColumn('target_scope')?->getLabel())->toBe('Target scope'); + expect($table->getColumn('entra_tenant_id')?->getLabel())->toBe('Microsoft tenant ID'); expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue(); expect($table->getColumn('migration_review_required'))->not->toBeNull(); expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(8); diff --git a/apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php b/apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php index b9b7a0cf..d2ddf07e 100644 --- a/apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php +++ b/apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php @@ -35,6 +35,24 @@ ->assertDontSee('Unauthorized Tenant Connection'); }); +test('non-members cannot reach provider connection detail target-scope metadata', function (): void { + $tenant = Tenant::factory()->create(); + $otherTenant = Tenant::factory()->create(); + + [$user] = createUserWithTenant($otherTenant, role: 'owner'); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'display_name' => 'Hidden Scope Connection', + 'entra_tenant_id' => '77777777-7777-7777-7777-777777777777', + ]); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('view', ['record' => $connection], tenant: $tenant)) + ->assertNotFound(); +}); + test('members without capability see provider connection actions disabled with standard tooltip', function () { $tenant = Tenant::factory()->create(); [$user] = createUserWithTenant($tenant, role: 'readonly'); @@ -99,3 +117,26 @@ ->assertActionVisible('edit') ->assertActionEnabled('edit'); }); + +test('sensitive provider connection mutations remain confirmation and capability gated', function (): void { + $source = (string) file_get_contents(repo_path('apps/platform/app/Filament/Resources/ProviderConnectionResource.php')); + + foreach ([ + 'makeSetDefaultAction', + 'makeEnableDedicatedOverrideAction', + 'makeRotateDedicatedCredentialAction', + 'makeDeleteDedicatedCredentialAction', + 'makeRevertToPlatformAction', + 'makeEnableConnectionAction', + 'makeDisableConnectionAction', + ] as $method) { + $start = strpos($source, 'public static function '.$method); + expect($start)->not->toBeFalse(); + + $next = strpos($source, "\n public static function ", $start + 1); + $block = substr($source, $start, $next === false ? null : $next - $start); + + expect($block)->toContain('->requiresConfirmation()') + ->and($block)->toContain('->requireCapability('); + } +}); diff --git a/apps/platform/tests/Feature/Filament/TableStandardsCriticalListsTest.php b/apps/platform/tests/Feature/Filament/TableStandardsCriticalListsTest.php index 20822a9c..30c2aca7 100644 --- a/apps/platform/tests/Feature/Filament/TableStandardsCriticalListsTest.php +++ b/apps/platform/tests/Feature/Filament/TableStandardsCriticalListsTest.php @@ -166,19 +166,21 @@ function spec125CriticalTenantContext(bool $ensureDefaultMicrosoftProviderConnec expect($table->persistsSearchInSession())->toBeTrue(); expect($table->persistsSortInSession())->toBeTrue(); expect($table->persistsFiltersInSession())->toBeTrue(); - expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found'); - expect($table->getEmptyStateDescription())->toBe('Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.'); + expect($table->getEmptyStateHeading())->toBe('No provider connections found'); + expect($table->getEmptyStateDescription())->toBe('Start with a platform-managed provider connection. Dedicated overrides are handled separately with stronger authorization.'); expect($table->getColumn('display_name')?->isSearchable())->toBeTrue(); expect($table->getColumn('display_name')?->isSortable())->toBeTrue(); - expect($table->getColumn('provider')?->isToggleable())->toBeTrue(); - expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue(); + expect($table->getColumn('provider')?->isToggleable())->toBeFalse(); + expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeFalse(); + expect($table->getColumn('target_scope')?->getLabel())->toBe('Target scope'); + expect($table->getColumn('entra_tenant_id')?->getLabel())->toBe('Microsoft tenant ID'); expect($table->getColumn('entra_tenant_id')?->isToggleable())->toBeTrue(); expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue(); expect($table->getColumn('last_error_reason_code')?->isToggleable())->toBeTrue(); expect($table->getColumn('last_error_reason_code')?->isToggledHiddenByDefault())->toBeTrue(); expect($table->getColumn('last_error_message')?->isToggleable())->toBeTrue(); expect($table->getColumn('last_error_message')?->isToggledHiddenByDefault())->toBeTrue(); - expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7); + expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(8); }); it('standardizes the findings list around open triage work with hidden forensic detail', function (): void { diff --git a/apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php b/apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php new file mode 100644 index 00000000..ec941bb3 --- /dev/null +++ b/apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php @@ -0,0 +1,36 @@ + \$record->entra_tenant_id", + "'entra_tenant_id' => (string) \$connection->entra_tenant_id", + ]; + + foreach ($paths as $path) { + $contents = (string) file_get_contents($root.'/'.$path); + + foreach ($forbiddenFragments as $fragment) { + expect($contents) + ->not->toContain($fragment, sprintf('%s still contains shared-surface provider-specific default prose [%s].', $path, $fragment)); + } + } +}); diff --git a/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php b/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php index c24e36eb..b7c70e88 100644 --- a/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php +++ b/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php @@ -99,7 +99,7 @@ ]); }); -it('renders the Entra tenant id placeholder for onboarding input guidance', function (): void { +it('renders neutral tenant id placeholder guidance for onboarding input', function (): void { $workspace = Workspace::factory()->create(); $user = User::factory()->create(); @@ -114,9 +114,39 @@ $this->actingAs($user) ->get('/admin/onboarding') ->assertSuccessful() + ->assertSee('Tenant ID (GUID)') ->assertSee('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', false); }); +it('uses target-scope wording in the onboarding provider setup step', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $entraTenantId = '34343434-3434-3434-3434-343434343434'; + + Livewire::actingAs($user) + ->test(ManagedTenantOnboardingWizard::class) + ->call('identifyManagedTenant', [ + 'entra_tenant_id' => $entraTenantId, + 'environment' => 'prod', + 'name' => 'Target Scope Tenant', + ]) + ->set('data.connection_mode', 'new') + ->assertSee('Target scope ID') + ->assertSee('The provider connection will point to this tenant target scope.') + ->assertSee($entraTenantId) + ->assertDontSee('Directory (tenant) ID') + ->assertDontSee('Graph API calls'); +}); + it('renders review summary guidance and activation consequences for ready onboarding sessions', function (): void { $workspace = Workspace::factory()->create(); $user = User::factory()->create(); @@ -195,7 +225,7 @@ ->assertSuccessful() ->assertSee('Skipped - No bootstrap actions selected') ->assertSee('Tenant status will be set to Active.') - ->assertSee('The provider connection will be used for all Graph API calls.'); + ->assertSee('The provider connection will be used for provider API calls.'); }); it('renders selected bootstrap actions in the review summary before any bootstrap run starts', function (): void { diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php new file mode 100644 index 00000000..9227d9bb --- /dev/null +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php @@ -0,0 +1,114 @@ +create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'display_name' => 'Neutral connection', + 'entra_tenant_id' => '44444444-4444-4444-4444-444444444444', + ]); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('create', ['tenant_id' => $tenant->external_id], panel: 'admin')) + ->assertOk() + ->assertSee('Target scope ID') + ->assertSee('Target scope') + ->assertDontSee('Entra tenant ID'); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection, 'tenant_id' => $tenant->external_id], panel: 'admin')) + ->assertOk() + ->assertSee('Target scope ID') + ->assertSee('Target scope') + ->assertDontSee('Entra tenant ID'); +}); + +it('keeps list and detail surfaces default-visible around provider target scope consent and verification', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'display_name' => 'Scope-visible connection', + 'entra_tenant_id' => '55555555-5555-5555-5555-555555555555', + 'consent_status' => 'granted', + 'verification_status' => 'healthy', + ]); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::actingAs($user)->test(ListProviderConnections::class); + $table = $component->instance()->getTable(); + $visibleColumnNames = collect($table->getVisibleColumns()) + ->map(fn ($column): string => $column->getName()) + ->values() + ->all(); + + expect($visibleColumnNames)->toContain('provider', 'target_scope', 'consent_status', 'verification_status') + ->and($visibleColumnNames)->not->toContain('entra_tenant_id') + ->and($table->getColumn('target_scope')?->getLabel())->toBe('Target scope') + ->and($table->getColumn('entra_tenant_id')?->getLabel())->toBe('Microsoft tenant ID'); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('view', ['record' => $connection, 'tenant_id' => $tenant->external_id], panel: 'admin')) + ->assertOk() + ->assertSee('Target scope') + ->assertSee('Provider identity details') + ->assertSee('Microsoft tenant ID') + ->assertSee('Consent') + ->assertSee('Verification') + ->assertDontSee('Entra tenant ID'); +}); + +it('uses neutral validation attributes when the create flow misses target-scope context', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(CreateProviderConnection::class) + ->fillForm([ + 'display_name' => 'Missing target scope', + 'entra_tenant_id' => '', + 'is_default' => true, + ]) + ->call('create') + ->assertHasFormErrors(['entra_tenant_id' => 'required']); +}); + +it('blocks unsupported provider target-scope combinations before provider execution', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); + + $connection = ProviderConnection::factory()->consentGranted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'contoso', + 'display_name' => 'Unsupported provider connection', + 'entra_tenant_id' => '66666666-6666-6666-6666-666666666666', + 'is_enabled' => true, + ]); + + $resolution = app(ProviderConnectionResolver::class) + ->validateConnection($tenant, 'contoso', $connection->fresh(['tenant'])); + + expect($user)->not->toBeNull() + ->and($resolution->resolved)->toBeFalse() + ->and($resolution->reasonCode)->toBe('provider_binding_unsupported') + ->and($resolution->extensionReasonCode)->toBe('ext.connection_scope_unsupported') + ->and($resolution->message)->toBe('This provider and target-scope combination is not supported.'); +}); diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php index 5ffbd425..0cd6d7e2 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php @@ -38,11 +38,14 @@ $this->get(ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => $connection], panel: 'admin')) ->assertOk() ->assertSee('Spec081 Connection') + ->assertSee('Target scope') + ->assertSee('Target scope ID') ->assertSee('Lifecycle') ->assertSee('Enabled') ->assertSee('Verification') ->assertSee('Migration review') ->assertSee('Review required') + ->assertDontSee('Entra tenant ID') ->assertDontSee('Diagnostic status') ->assertDontSee('Diagnostic health'); }); diff --git a/apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php b/apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php index 498c6c21..f88f56df 100644 --- a/apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php +++ b/apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php @@ -39,12 +39,19 @@ $blocked = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'blocked'); expect($blocked->color)->toBe('danger'); expect($blocked->label)->toBe('Blocked'); - + $degraded = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'degraded'); expect($degraded->color)->toBe('warning'); expect($degraded->label)->toBe('Degraded'); }); +it('does not reuse consent labels for provider verification summaries', function (): void { + expect(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'required')->label)->toBe('Required') + ->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'pending')->label)->toBe('Pending') + ->and(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'required')->label) + ->not->toBe(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'pending')->label); +}); + it('does not expose legacy provider status badge domains anymore', function (): void { $domainValues = collect(BadgeDomain::cases()) ->map(fn (BadgeDomain $domain): string => $domain->value) diff --git a/apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php b/apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php index 9b030de0..b319fdd0 100644 --- a/apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php +++ b/apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php @@ -18,6 +18,13 @@ ->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'blocked')->label)->toBe('Blocked'); }); +it('keeps consent and verification badge domains distinct for provider connection summaries', function (): void { + expect(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'granted')->label)->toBe('Granted') + ->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'healthy')->label)->toBe('Healthy') + ->and(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'granted')->label) + ->not->toBe(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'healthy')->label); +}); + it('maps managed-tenant onboarding verification badge aliases consistently', function (): void { expect(BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'unknown')->label)->toBe('Not started') ->and(BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'healthy')->label)->toBe('Ready') diff --git a/apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php b/apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php new file mode 100644 index 00000000..4746061b --- /dev/null +++ b/apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php @@ -0,0 +1,63 @@ +create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', + 'display_name' => 'Primary connection', + ]); + + $descriptor = app(ProviderConnectionTargetScopeNormalizer::class) + ->descriptorForConnection($connection->fresh(['tenant'])); + $summary = ProviderConnectionSurfaceSummary::forConnection($connection->fresh(['tenant'])); + + expect($user)->not->toBeNull() + ->and($descriptor->provider)->toBe('microsoft') + ->and($descriptor->scopeKind)->toBe(ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT) + ->and($descriptor->scopeIdentifier)->toBe('11111111-1111-1111-1111-111111111111') + ->and($descriptor->sharedLabel)->toBe('Target scope') + ->and($descriptor->summary())->toContain((string) $tenant->name) + ->and($summary->targetScopeSummary())->toContain('11111111-1111-1111-1111-111111111111') + ->and($summary->contextualIdentityDetails)->toHaveCount(1) + ->and($summary->contextualIdentityDetails[0]->detailLabel)->toBe('Microsoft tenant ID'); +}); + +it('blocks unsupported provider-scope combinations explicitly instead of inheriting Microsoft defaults', function (): void { + $result = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput( + provider: 'unknown-provider', + scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT, + scopeIdentifier: 'scope-1', + scopeDisplayName: 'Scope 1', + ); + + expect($result['status'])->toBe(ProviderConnectionTargetScopeNormalizer::STATUS_BLOCKED) + ->and($result['failure_code'])->toBe(ProviderConnectionTargetScopeNormalizer::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION) + ->and($result['message'])->toContain('not supported'); +}); + +it('blocks missing target-scope context with neutral validation language', function (): void { + $result = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput( + provider: 'microsoft', + scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT, + scopeIdentifier: '', + scopeDisplayName: 'Missing scope', + ); + + expect($result['status'])->toBe(ProviderConnectionTargetScopeNormalizer::STATUS_BLOCKED) + ->and($result['failure_code'])->toBe(ProviderConnectionTargetScopeNormalizer::FAILURE_MISSING_PROVIDER_CONTEXT) + ->and($result['message'])->toBe('A target scope identifier is required for this provider connection.'); +}); diff --git a/apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php b/apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php new file mode 100644 index 00000000..f96e3c92 --- /dev/null +++ b/apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php @@ -0,0 +1,62 @@ +set('graph.client_id', 'platform-client-id'); + config()->set('graph.client_secret', 'platform-client-secret'); + config()->set('graph.tenant_id', 'platform-home-tenant-id'); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => '22222222-2222-2222-2222-222222222222', + ]); + + $connection = ProviderConnection::factory()->platform()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => '22222222-2222-2222-2222-222222222222', + ]); + + $resolution = app(ProviderIdentityResolver::class)->resolve($connection->fresh(['tenant'])); + + expect($resolution->resolved)->toBeTrue() + ->and($resolution->connectionType)->toBe(ProviderConnectionType::Platform) + ->and($resolution->tenantContext)->toBe('22222222-2222-2222-2222-222222222222') + ->and($resolution->targetScope)->not->toBeNull() + ->and($resolution->targetScope?->scopeKind)->toBe(ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT) + ->and($resolution->targetScope?->scopeIdentifier)->toBe('22222222-2222-2222-2222-222222222222') + ->and(collect($resolution->contextualIdentityDetails)->pluck('detailKey')->all()) + ->toContain('microsoft_tenant_id', 'authority_tenant', 'redirect_uri'); +}); + +it('keeps dedicated runtime credentials out of the shared target-scope descriptor', function (): void { + $connection = ProviderConnection::factory()->dedicated()->create([ + 'entra_tenant_id' => '33333333-3333-3333-3333-333333333333', + ]); + + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + 'payload' => [ + 'client_id' => 'dedicated-client-id', + 'client_secret' => 'dedicated-client-secret', + ], + ]); + + $resolution = app(ProviderIdentityResolver::class)->resolve($connection->fresh(['tenant', 'credential'])); + + expect($resolution->resolved)->toBeTrue() + ->and($resolution->targetScope?->toArray())->not->toHaveKey('client_id') + ->and($resolution->targetScope?->toArray())->not->toHaveKey('client_secret') + ->and($resolution->effectiveClientId)->toBe('dedicated-client-id'); +}); diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index 2b3c4fe5..c6f89b2c 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -3,7 +3,7 @@ # Product Roadmap > Strategic thematic blocks and release trajectory. > This is the "big picture" — not individual specs. -**Last updated**: 2026-04-24 +**Last updated**: 2026-04-25 --- @@ -90,6 +90,7 @@ ### R1.x Foundation Hardening — Governance Platform Anti-Drift - Provider Boundary Hardening so provider-specific behavior stays inside provider adapters and registries - Provider Identity & Target Scope Neutrality so Entra-specific identifiers do not become generic platform truth - Platform Vocabulary Boundary Enforcement for Governed Subject Keys so `policy_type` and similar provider/domain terms do not leak into the platform core +- Codebase Quality & Engineering Maturity hardening so the platform remains enterprise-maintainable while the governance surface grows: System Panel least-privilege capabilities, static-analysis baseline, architecture-boundary guard tests, and targeted decomposition of large Filament/service hotspots - No AWS/GCP/SaaS connector implementation in this slice; this is anti-drift foundation work only ### R2 Completion — Evidence & Exception Workflows @@ -226,8 +227,9 @@ ## Infrastructure & Platform Debt | Item | Risk | Status | |------|------|--------| | No `.env.example` in repo | Onboarding friction | Open | -| CI pipeline config status drift | Roadmap debt list may be stale because workflow files exist and should be re-audited | Review needed | -| No PHPStan/Larastan | No static analysis | Open | +| CI pipeline config status drift | Roadmap debt list may be stale because workflow files exist and should be re-audited; align with static-analysis and architecture-boundary gates | Review needed | +| No PHPStan/Larastan | No static analysis; covered by `Static Analysis Baseline for Platform Code` spec candidate | Open | +| Thin architecture-boundary enforcement | Product tests are strong, but architecture-level guardrails need expansion; covered by `Architecture Boundary Guard Tests` spec candidate | Open | | SQLite for tests vs PostgreSQL in prod | Schema drift risk | Open | | No formal release process | Manual deploys | Open | | Dokploy config external to repo | Env drift | Open | diff --git a/docs/product/spec-candidates.md b/docs/product/spec-candidates.md index 6b4d88c2..5256dd9d 100644 --- a/docs/product/spec-candidates.md +++ b/docs/product/spec-candidates.md @@ -5,7 +5,7 @@ # Spec Candidates > > **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec` -**Last reviewed**: 2026-04-24 (added Platform Hardening — OperationRun UX Consistency cluster with OperationRun Start UX Contract, Generic Active Run Surface, OperationRun Notification Lifecycle, and OperationRun Startsurface Migration; promoted Provider Boundary Hardening to Spec 237, clarified the remaining near-term sequencing after Canonical Control Catalog Foundation and Provider Boundary Hardening, and retained `Customer Review Workspace v1` as the customer-facing review consumption candidate that sharpens the R2 read-only/customer review lane) +> **Last reviewed**: 2026-04-25 (added Codebase Quality & Engineering Maturity cluster from full codebase audit with System Panel Least-Privilege Capability Model, Static Analysis Baseline, Architecture Boundary Guard Tests, Filament Hotspot Decomposition Foundation, and RestoreService Responsibility Split; retained OperationRun UX Consistency and Provider Boundary hardening sequences as current strategic hardening lanes) --- @@ -78,6 +78,202 @@ ## Qualified > Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining risk is semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage. +> Codebase Quality & Engineering Maturity cluster: these candidates come from the full codebase quality audit on 2026-04-25. The audit classified the repo as **good / product-capable, not bad coding**, but identified a small set of structural risks that should be handled before larger feature expansion: coarse System Panel platform visibility, missing static-analysis gates, thin architecture-boundary enforcement, and several large Filament/service hotspots. This cluster is intentionally hardening-focused; it must not become a broad rewrite or cosmetic cleanup campaign. + +### System Panel Least-Privilege Capability Model +- **Type**: security hardening / platform-plane RBAC +- **Source**: full codebase quality audit 2026-04-25 — tenant/workspace-plane isolation is strong, but System Panel directory visibility is intentionally global and currently gated by coarse platform capabilities +- **Problem**: The System Panel currently exposes global workspace and tenant directory views through broad platform capabilities. This is acceptable for trusted platform superadmins and break-glass operators, but too coarse for enterprise-grade least-privilege support roles, audit expectations, and future support delegation. +- **Why it matters**: TenantPilot has strong tenant/workspace isolation elsewhere. If the platform plane remains coarse, the product has an uneven security story: customer-facing tenant access is tight, while internal/operator metadata visibility can still be broader than necessary. Enterprise customers, MSP operators, and auditors will expect support roles to see only the minimum system metadata needed for their task. +- **Proposed direction**: + - split broad System Panel directory visibility into more granular platform capabilities + - distinguish System Panel access, workspace directory visibility, tenant directory visibility, operations visibility, support diagnostics, and break-glass access + - keep platform superadmin and emergency break-glass behavior intact + - enforce the new boundaries server-side on System Panel pages, not only through navigation hiding + - add explicit tests for restricted platform users so unrelated workspace/tenant metadata cannot be enumerated accidentally +- **Candidate capabilities**: + - `platform.system.access` + - `platform.workspaces.view` + - `platform.tenants.view` + - `platform.operations.view` + - `platform.support_diagnostics.view` + - `platform.break_glass.use` +- **Scope boundaries**: + - **In scope**: System Panel page access, platform capability split, server-side authorization checks, navigation visibility alignment, audit-friendly role behavior, and regression tests for non-superadmin platform users + - **Out of scope**: redesigning tenant/workspace membership RBAC, changing admin-panel tenant isolation semantics, removing break-glass, adding impersonation, or building a full support-role management UI unless explicitly needed for test fixtures +- **Acceptance points**: + - existing platform superadmin behavior remains intact + - a platform user with only workspace-directory visibility cannot view tenant-directory pages + - a platform user with only tenant-directory visibility cannot view workspace-directory pages unless explicitly granted + - operations visibility is separately controllable from directory visibility + - System Panel pages return forbidden or not-found consistently when capability is missing + - tests prove navigation hiding is not the only protection +- **Risks / open questions**: + - Over-fragmenting capabilities could make platform-user administration noisy before there is a polished role UI + - The product needs an explicit decision on whether support diagnostics can reveal tenant metadata without full tenant-directory access + - Break-glass behavior must remain simple, auditable, and unmistakably separate from normal support access +- **Dependencies**: `PlatformCapabilities`, System Panel providers/pages, platform-user model/policies, existing System Directory tests, existing tenant/workspace isolation tests +- **Related specs / candidates**: enterprise auth structure, platform superadmin / break-glass rules, RBAC hardening, System Directory residual surface tests +- **Strategic sequencing**: First item in this cluster because it is the only finding with direct enterprise security / least-privilege implications. +- **Priority**: high + +### Static Analysis Baseline for Platform Code +- **Type**: quality gate / developer experience hardening +- **Source**: full codebase quality audit 2026-04-25 — the repo has strong Pest and lane-based tests but no visible PHPStan/Larastan/Psalm/Rector gate +- **Problem**: Runtime tests and feature tests are strong, but the codebase lacks a visible static-analysis baseline. In a growing Laravel / Filament / Livewire codebase with large services and resources, relying only on runtime tests leaves type drift, unsafe API usage, dead paths, and refactoring regressions too easy to introduce. +- **Why it matters**: TenantPilot is increasingly agent-assisted and spec-driven. Agents can move quickly, but without static analysis they can also reinforce invalid assumptions across dynamic Laravel boundaries. A pragmatic static-analysis gate gives both humans and agents a fast feedback loop before full suites run. +- **Proposed direction**: + - add Larastan/PHPStan configuration for `apps/platform` + - start at a realistic level rather than attempting perfect strictness on day one + - generate an explicit baseline if existing findings are too broad for immediate cleanup + - make CI fail on new non-baselined findings + - document the local and CI workflow for developers and repo agents + - track baseline reduction as a future maintenance path rather than bundling all fixes into this spec +- **Scope boundaries**: + - **In scope**: PHPStan/Larastan setup, baseline generation if needed, CI integration, developer documentation, and a small number of configuration fixes required to make analysis meaningful for Laravel/Filament patterns + - **Out of scope**: fixing all existing static-analysis findings, broad refactoring, Rector-driven code rewrites, changing app architecture, or blocking unrelated feature delivery on full strictness immediately +- **Acceptance points**: + - static analysis runs locally for `apps/platform` + - static analysis runs in CI or the active repository pipeline + - existing accepted findings are captured in a reviewed baseline + - new non-baselined findings fail the quality gate + - README, handover, or developer docs explain how to run and update the baseline + - configuration accounts for Laravel, Filament, Eloquent factories, and dynamic container usage where appropriate +- **Risks / open questions**: + - Starting too strict could create a large noisy cleanup spec instead of a useful guardrail + - Starting too loose could give false confidence without catching meaningful drift + - The repo must decide whether PHPStan/Larastan is enough initially or whether Rector belongs in a later separate modernization lane +- **Dependencies**: current Composer tooling, Pest lanes, Gitea workflows, `apps/platform/phpunit.xml`, developer documentation +- **Related specs / candidates**: Architecture Boundary Guard Tests, codebase quality hardening, CI/DX hardening +- **Strategic sequencing**: Second item in this cluster. It should land before broad hotspot refactors so those refactors have stronger safety rails. +- **Priority**: high + +### Architecture Boundary Guard Tests +- **Type**: architecture hardening / regression guardrail +- **Source**: full codebase quality audit 2026-04-25 — product tests are strong, but architecture-level enforcement is still thin compared with the size and complexity of the codebase +- **Problem**: The repo has strong feature, RBAC, browser, and operation-flow tests, but only limited architecture-boundary enforcement. As the platform grows, Filament UI, services, jobs, provider code, models, support registries, and operation-run semantics can drift silently unless dependency and responsibility rules are executable. +- **Why it matters**: TenantPilot already has clear architectural intent: UI should not become provider-write logic, jobs should delegate business logic, platform and tenant capabilities should remain separate, and operation-run semantics should stay service-owned. Without guard tests, these principles remain review conventions and can be weakened by future agent-led changes. +- **Proposed direction**: + - introduce architecture tests that encode the most important dependency and responsibility boundaries + - start with high-signal rules rather than broad brittle pattern matching + - baseline or explicitly document accepted legacy violations + - connect new tests to the active quality-gate lane + - use the tests as a safety rail before decomposing large Filament/service hotspots +- **Candidate guardrails**: + - Filament Resources must not directly perform provider writes + - Filament Resources must not own large workflow orchestration + - Jobs should delegate business logic to services or handlers + - provider-specific code must not leak into neutral platform domains + - Models must not depend on Filament + - Services must not depend on Filament Resources + - Support registries must not depend on UI classes + - platform capabilities and tenant/workspace capabilities must remain separated + - OperationRun lifecycle and outcome semantics stay service-owned +- **Scope boundaries**: + - **In scope**: executable architecture tests, pragmatic baselines/exceptions, dependency-direction checks, responsibility-boundary checks, and CI integration + - **Out of scope**: perfect clean-architecture purity, mass refactoring to satisfy idealized rules, changing Laravel/Filament conventions where the framework reasonably expects dynamic coupling, or enforcing line-count thresholds as the only quality metric +- **Acceptance points**: + - architecture tests run locally and in CI + - new violations for selected boundaries fail tests + - accepted existing violations are explicitly documented with exit paths or reasons + - tests protect at least UI/provider, model/UI, service/UI, platform-capability, and OperationRun ownership boundaries + - the rules are specific enough to guide future agent work without blocking legitimate Laravel/Filament usage +- **Risks / open questions**: + - Over-broad static rules may produce noise and encourage blanket exceptions + - Some legacy hotspots may need temporary exceptions until decomposition specs land + - The tests should complement, not duplicate, PHPStan/Larastan +- **Dependencies**: Static Analysis Baseline for Platform Code, current architecture test setup, existing Action Surface guard tests, platform capability registry, provider contracts +- **Related specs / candidates**: Static Analysis Baseline for Platform Code, Filament Hotspot Decomposition Foundation, RestoreService Responsibility Split, Provider Boundary Hardening +- **Strategic sequencing**: Third item in this cluster. It can begin alongside static analysis but should be in place before large decomposition work accelerates. +- **Priority**: high + +### Filament Hotspot Decomposition Foundation +- **Type**: maintainability hardening / UI architecture +- **Source**: full codebase quality audit 2026-04-25 — several Filament Resources/Pages are large multi-responsibility hotspots despite an otherwise structured architecture +- **Problem**: Several Filament surfaces have grown into large classes that combine table/query construction, form or infolist schema, action definitions, presentation rules, state labels, authorization glue, notifications, and workflow orchestration. This does not make the codebase bad, but it increases review cost, bus-factor risk, regression risk, and future feature cost. +- **Known hotspots**: + - `ManagedTenantOnboardingWizard.php` + - `TenantResource.php` + - `FindingResource.php` + - `RestoreRunResource.php` +- **Why it matters**: Filament is the primary operator UI. If every major surface keeps accumulating local query, action, presenter, and workflow code, the admin experience becomes hard to evolve safely. This is especially risky in an agent-led workflow where large files encourage local patching rather than clean extraction. +- **Proposed direction**: + - define a repeatable decomposition pattern for large Filament Resources and Pages + - extract complex query builders into dedicated query/read-model objects where useful + - extract action construction into action builder classes or surface-specific action objects + - extract badge, label, state, and helper-text rules into presenters + - extract complex form/infolist/table section schemas into reusable schema builders + - keep routes, resource names, permissions, and user-facing behavior unchanged during the foundation slice + - adopt the pattern on one representative Resource first before migrating all hotspots +- **First adoption target**: Prefer `FindingResource.php` or `TenantResource.php` as the first representative target because both expose dense operator-facing surfaces and repeated action/presentation/query patterns. +- **Scope boundaries**: + - **In scope**: decomposition pattern, first representative Resource/Page adoption, tests proving behavior is unchanged, and one or more architecture guardrails that prevent immediate regression + - **Out of scope**: broad UI redesign, changing product behavior, permission-semantic changes, schema changes, visual redesign, or mass migration of every large Filament surface in one spec +- **Acceptance points**: + - selected Resource/Page loses meaningful line count without changing behavior + - extracted classes have clear responsibility names and are easier to test or review + - existing UI/feature tests pass unchanged or are updated only for intentional structure-aware guardrails + - new or updated architecture tests prevent action/query/presenter logic from growing back into the Resource in the same form + - the resulting pattern is documented so future specs and agents can reuse it +- **Risks / open questions**: + - Extracting too aggressively could create more indirection than clarity + - Extracting too little would reduce line count without actually improving responsibility boundaries + - Choosing the first adoption surface matters; a volatile feature surface may make behavior-preserving decomposition harder +- **Dependencies**: Static Analysis Baseline for Platform Code, Architecture Boundary Guard Tests, existing Filament resource tests, action-surface guard tests +- **Related specs / candidates**: Record Page Header Discipline & Contextual Navigation (Spec 192), Monitoring Surface Action Hierarchy & Workbench Semantics (Spec 193), Governance Friction & Operator Vocabulary Hardening (Spec 194), RestoreService Responsibility Split +- **Strategic sequencing**: Fourth item in this cluster. It should follow static analysis and initial architecture guardrails so the extraction work is safer and easier to review. +- **Priority**: high + +### RestoreService Responsibility Split +- **Type**: maintainability hardening / safety-critical workflow architecture +- **Source**: full codebase quality audit 2026-04-25 — restore logic is safety-critical but currently concentrated in a large service hotspot +- **Problem**: `RestoreService.php` has grown into a large multi-responsibility class. Restore is one of TenantPilot's highest-risk workflows because it can affect customer tenant state. Concentrating preview, validation, payload mapping, provider writes, operation tracking, result normalization, and failure classification in one service increases regression risk and makes review harder. +- **Why it matters**: Restore is not just another CRUD operation. Operators need predictable preview/apply semantics, accurate failure handling, and auditable operation results. A large service can still work, but it becomes increasingly difficult to change safely, especially as provider-backed actions and restore semantics mature. +- **Proposed direction**: + - keep `RestoreService` as a thin application-facing facade if preserving its public API is useful + - extract restore preview calculation into a focused collaborator + - extract restore payload mapping into provider-aware mappers + - extract restore validation / precondition checks into a dedicated validator or gate + - extract provider write execution into explicit execution handlers + - extract restore result normalization and failure classification into focused components + - preserve existing OperationRun and audit semantics +- **Target responsibility slices**: + - restore preview calculation + - restore payload mapping + - restore validation and preconditions + - provider write execution + - restore operation/run tracking + - restore result normalization + - restore failure classification +- **Scope boundaries**: + - **In scope**: internal responsibility split, behavior-preserving tests, collaborator extraction, thin facade preservation where appropriate, and restore-specific architecture guardrails + - **Out of scope**: changing restore UI, changing provider behavior, changing restore operation semantics, adding new restore features, broad provider abstraction redesign, or rewriting the restore engine from scratch +- **Acceptance points**: + - `RestoreService.php` becomes materially smaller + - each extracted class has one clear responsibility + - existing restore tests pass + - new tests cover at least preview, validation/preconditions, provider write execution, and failure/result handling boundaries + - OperationRun lifecycle and audit behavior remain unchanged + - the public restore workflow remains behavior-compatible unless an explicit spec requirement says otherwise +- **Risks / open questions**: + - Restore has real execution risk; decomposition must be behavior-preserving and heavily tested + - Poor extraction could hide execution order or transactional semantics across too many classes + - Provider-boundary cleanup and restore decomposition must be coordinated so neither creates competing abstractions +- **Dependencies**: Static Analysis Baseline for Platform Code, Architecture Boundary Guard Tests, restore tests, OperationRun semantics, Provider Boundary Hardening +- **Related specs / candidates**: Restore Lifecycle Semantic Clarity, Provider-Backed Action Preflight and Dispatch Gate Unification (Spec 216), Provider Boundary Hardening (Spec 237), Filament Hotspot Decomposition Foundation +- **Strategic sequencing**: Fifth item in this cluster. It should follow or run shortly after the generic quality gates, but it can be promoted earlier if restore changes become frequent. +- **Priority**: high + +> Recommended sequence for this cluster: +> 1. **System Panel Least-Privilege Capability Model** +> 2. **Static Analysis Baseline for Platform Code** +> 3. **Architecture Boundary Guard Tests** +> 4. **Filament Hotspot Decomposition Foundation** +> 5. **RestoreService Responsibility Split** +> +> Why this order: first close the enterprise security/least-privilege gap, then add quality gates, then protect architecture boundaries, and only then start behavior-preserving decomposition of the largest UI/service hotspots. This avoids a broad rewrite while directly addressing the audit's highest-leverage risks. + + > Platform Hardening — OperationRun UX Consistency cluster: these candidates prevent OperationRun-starting features from drifting into surface-local UX behavior. The goal is not to rebuild the Operations Hub, progress system, or notification architecture in one step. The immediate priority is to make OperationRun start UX contract-driven so new features cannot hand-roll local toasts, operation links, browser events, and queued-notification decisions independently. ### OperationRun Start UX Contract diff --git a/specs/238-provider-identity-target-scope/checklists/requirements.md b/specs/238-provider-identity-target-scope/checklists/requirements.md new file mode 100644 index 00000000..19a5c0a6 --- /dev/null +++ b/specs/238-provider-identity-target-scope/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Provider Identity & Target Scope Neutrality + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-24 +**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 + +- The spec stays bounded to provider connection identity and target-scope semantics on existing shared surfaces. +- Broader governed-subject and compare-boundary work remains an explicit follow-up, not hidden scope inside this draft. \ No newline at end of file diff --git a/specs/238-provider-identity-target-scope/contracts/provider-identity-target-scope.logical.openapi.yaml b/specs/238-provider-identity-target-scope/contracts/provider-identity-target-scope.logical.openapi.yaml new file mode 100644 index 00000000..6fb2e083 --- /dev/null +++ b/specs/238-provider-identity-target-scope/contracts/provider-identity-target-scope.logical.openapi.yaml @@ -0,0 +1,361 @@ +openapi: 3.1.0 +info: + title: Provider Identity & Target Scope Neutrality Logical Contract + version: 0.1.0 + description: | + Logical internal contract for the provider connection target-scope neutrality + slice. It describes the normalized shared target-scope descriptor, the + operator-facing surface summary, and the bounded neutrality guard result. + It is not a commitment to expose public HTTP routes. + Review stop: shared provider connection surfaces must use neutral target + scope truth by default, carry provider-owned Microsoft identity only as + contextual metadata, and resolve remaining provider-boundary drift through + document-in-feature or follow-up-spec disposition instead of silent shared + platform truth. +paths: + /logical/provider-connections/{connectionId}/target-scope: + get: + summary: Read the normalized target-scope descriptor for an existing provider connection + operationId: getProviderConnectionTargetScope + parameters: + - name: connectionId + in: path + required: true + schema: + type: integer + responses: + '200': + description: Normalized target-scope descriptor + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderConnectionTargetScopeDescriptor' + /logical/provider-connections/target-scope/normalize: + post: + summary: Normalize create or edit input into shared target-scope truth plus contextual provider metadata + operationId: normalizeProviderConnectionTargetScope + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderConnectionTargetScopeInput' + responses: + '200': + description: Normalized descriptor and optional shared-surface preview + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderConnectionTargetScopeNormalizationSuccess' + '422': + description: Unsupported provider-scope combination or missing provider context + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderConnectionTargetScopeNormalizationFailure' + /logical/provider-connections/{connectionId}/surface-summary: + get: + summary: Read the default-visible operator-facing summary for a shared provider connection surface + operationId: getProviderConnectionSurfaceSummary + parameters: + - name: connectionId + in: path + required: true + schema: + type: integer + responses: + '200': + description: Shared-surface summary + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderConnectionSurfaceSummary' + /logical/provider-connections/neutrality/evaluate: + post: + summary: Evaluate whether a touched shared provider-connection path preserves neutral target-scope truth + operationId: evaluateProviderConnectionNeutrality + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderConnectionNeutralityEvaluationRequest' + responses: + '200': + description: Neutrality evaluation result + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderConnectionNeutralityCheckResult' +components: + schemas: + ProviderConnectionSupportedScopeKind: + type: string + enum: + - tenant + ProviderIdentityContextVisibility: + type: string + enum: + - contextual_only + - audit_only + - troubleshooting_only + ProviderIdentityContextMetadata: + type: object + properties: + provider: + type: string + detail_key: + type: string + detail_label: + type: string + detail_value: + type: string + visibility: + $ref: '#/components/schemas/ProviderIdentityContextVisibility' + required: + - provider + - detail_key + - detail_label + - detail_value + - visibility + ProviderConnectionTargetScopeDescriptor: + type: object + properties: + provider: + type: string + scope_kind: + $ref: '#/components/schemas/ProviderConnectionSupportedScopeKind' + scope_identifier: + type: string + scope_display_name: + type: string + shared_label: + type: string + shared_help_text: + type: string + required: + - provider + - scope_kind + - scope_identifier + - scope_display_name + - shared_label + - shared_help_text + ProviderConnectionTargetScopeInput: + type: object + properties: + provider: + type: string + scope_kind: + $ref: '#/components/schemas/ProviderConnectionSupportedScopeKind' + scope_identifier: + type: string + scope_display_name: + type: string + provider_specific_identity: + type: object + additionalProperties: + type: string + required: + - provider + - scope_kind + - scope_identifier + - scope_display_name + ProviderConnectionTargetScopeNormalizationSuccess: + type: object + properties: + status: + type: string + enum: + - normalized + provider: + type: string + scope_kind: + $ref: '#/components/schemas/ProviderConnectionSupportedScopeKind' + target_scope: + $ref: '#/components/schemas/ProviderConnectionTargetScopeDescriptor' + contextual_identity_details: + type: array + items: + $ref: '#/components/schemas/ProviderIdentityContextMetadata' + failure_code: + type: string + enum: + - none + message: + type: string + preview_summary: + oneOf: + - $ref: '#/components/schemas/ProviderConnectionSurfaceSummary' + - type: 'null' + required: + - status + - provider + - scope_kind + - target_scope + - contextual_identity_details + - failure_code + - message + - preview_summary + ProviderConnectionTargetScopeNormalizationFailure: + type: object + properties: + status: + type: string + enum: + - blocked + provider: + type: string + scope_kind: + $ref: '#/components/schemas/ProviderConnectionSupportedScopeKind' + failure_code: + type: string + enum: + - unsupported_provider_scope_combination + - missing_provider_context + message: + type: string + required: + - status + - provider + - scope_kind + - failure_code + - message + ProviderConnectionSurfaceSummary: + type: object + properties: + provider: + type: string + target_scope: + $ref: '#/components/schemas/ProviderConnectionTargetScopeDescriptor' + consent_state: + type: string + verification_state: + type: string + readiness_summary: + type: string + contextual_identity_details: + type: array + items: + $ref: '#/components/schemas/ProviderIdentityContextMetadata' + required: + - provider + - target_scope + - consent_state + - verification_state + - readiness_summary + - contextual_identity_details + ProviderConnectionNeutralityEvaluationRequest: + type: object + properties: + surface_key: + type: string + path: + type: string + surface_ownership: + type: string + enum: + - platform_core + - provider_owned + default_labels: + type: array + items: + type: string + required_fields: + type: array + items: + type: string + filter_labels: + type: array + items: + type: string + validation_messages: + type: array + items: + type: string + helper_copy: + type: array + items: + type: string + audit_prose: + type: array + items: + type: string + allowed_exception_classes: + type: array + items: + type: string + provider: + type: string + provider_owned_context: + type: array + items: + type: string + required: + - surface_key + - path + - surface_ownership + - default_labels + - required_fields + - filter_labels + - validation_messages + - helper_copy + - audit_prose + - allowed_exception_classes + - provider + - provider_owned_context + ProviderConnectionNeutralityCheckResult: + type: object + description: | + Guard result for touched shared provider connection surfaces. A blocked + or review_required result must name whether the issue is a default + label, filter, required field, validation message, helper copy, audit + prose, or missing target-scope descriptor, and must route the close-out + through document-in-feature or follow-up-spec. + properties: + status: + type: string + enum: + - allowed + - review_required + - blocked + surface_key: + type: string + path: + type: string + surface_ownership: + type: string + enum: + - platform_core + - provider_owned + allowed_exception_classes: + type: array + items: + type: string + violation_code: + type: string + enum: + - none + - provider_specific_default_label + - provider_specific_default_filter + - provider_specific_required_field + - provider_specific_validation_message + - provider_specific_default_helper_copy + - provider_specific_default_audit_prose + - missing_target_scope_descriptor + message: + type: string + suggested_follow_up: + type: string + enum: + - none + - document-in-feature + - follow-up-spec + required: + - status + - surface_key + - path + - surface_ownership + - allowed_exception_classes + - violation_code + - message + - suggested_follow_up diff --git a/specs/238-provider-identity-target-scope/data-model.md b/specs/238-provider-identity-target-scope/data-model.md new file mode 100644 index 00000000..deaf67e3 --- /dev/null +++ b/specs/238-provider-identity-target-scope/data-model.md @@ -0,0 +1,140 @@ +# Data Model: Provider Identity & Target Scope Neutrality + +## Overview + +This slice introduces one shared target-scope descriptor, one provider-owned contextual identity metadata shape, one operator-facing connection summary, and one narrow neutrality guard result. No new database persistence is introduced. + +## Entity: ProviderConnectionTargetScopeDescriptor + +- **Purpose**: Express the neutral platform meaning of what a provider connection points to without carrying provider-specific identity as part of the descriptor itself. +- **Identity**: + - `provider_connection_id` for existing records + - draft or create-flow input tuple `(provider, scope_kind, scope_identifier, scope_display_name)` before persistence +- **Core fields**: + - `provider` + - `scope_kind` + - `scope_identifier` + - `scope_display_name` + - `shared_label` + - `shared_help_text` +- **Validation rules**: + - The descriptor must remain renderable without provider-specific labels. + - In this current-release slice, `scope_kind` is tenant-only even though the neutral field remains generic for future provider-boundary-safe extension. + - `scope_kind`, `scope_identifier`, and `scope_display_name` must be sufficient to describe the neutral target-scope meaning on shared surfaces. + - `scope_identifier` and `scope_display_name` must be usable on shared surfaces without relying on Microsoft directory vocabulary. + - Provider-owned identity details must live beside the descriptor in contextual metadata or summary shapes, not inside the descriptor itself. + +## Entity: ProviderConnectionTargetScopeNormalizationResult + +- **Purpose**: Represent the deterministic result of normalizing create or edit input before persistence, including explicit blocked outcomes for unsupported combinations or missing provider context. +- **Fields**: + - `status` + - `provider` + - `scope_kind` + - `target_scope` when `status = normalized` + - `contextual_identity_details[]` when `status = normalized` + - `preview_summary` when a shared-surface preview can be derived without assuming persisted runtime state + - `failure_code` + - `message` +- **Status values**: + - `normalized` + - `blocked` +- **Failure code values**: + - `none` + - `unsupported_provider_scope_combination` + - `missing_provider_context` +- **Validation rules**: + - `normalized` results must carry `provider`, `scope_kind`, `target_scope`, `contextual_identity_details[]`, `failure_code = none`, and a human-readable `message`; they may include `preview_summary` only when consent and verification state can be derived without assuming persisted runtime state. + - `blocked` results must carry `provider`, `scope_kind`, `failure_code`, and `message` and must not pretend readiness or persisted summary truth exists. + - The normalization result must preserve the distinction between neutral target-scope truth and provider-owned contextual identity details. + +## Entity: ProviderIdentityContextMetadata + +- **Purpose**: Carry provider-owned identity details that remain necessary for Microsoft consent, verification, troubleshooting, or audit drill-in. +- **Fields**: + - `provider` + - `detail_key` + - `detail_label` + - `detail_value` + - `visibility` +- **Visibility values**: + - `contextual_only` + - `audit_only` + - `troubleshooting_only` +- **Validation rules**: + - Context metadata must never replace the shared target-scope descriptor on generic provider surfaces. + - Microsoft-only labels such as `Entra tenant ID` remain allowed only when `provider = microsoft` and visibility is contextual. + +## Entity: ProviderConnectionSurfaceSummary + +- **Purpose**: Define the default-visible operator-facing summary for shared provider connection surfaces. +- **Fields**: + - `provider` + - `target_scope` + - `consent_state` + - `verification_state` + - `readiness_summary` + - `contextual_identity_details[]` +- **Validation rules**: + - `provider`, `target_scope`, `consent_state`, and `verification_state` must all be visible without opening diagnostics. + - `contextual_identity_details[]` must remain secondary to the target-scope summary. + - Shared surface summaries must not collapse consent and verification into one ambiguous state. + +## Entity: ProviderConnectionNeutralityCheckResult + +- **Purpose**: Deterministic result shape used by guard tests and review checks. +- **Fields**: + - `status` + - `surface_key` + - `path` + - `surface_ownership` + - `allowed_exception_classes[]` + - `violation_code` + - `message` + - `suggested_follow_up` +- **Status values**: + - `allowed` + - `review_required` + - `blocked` +- **Violation code examples**: + - `provider_specific_default_label` + - `provider_specific_default_filter` + - `provider_specific_required_field` + - `provider_specific_validation_message` + - `provider_specific_default_helper_copy` + - `provider_specific_default_audit_prose` + - `missing_target_scope_descriptor` +- **Surface ownership values**: + - `platform_core` + - `provider_owned` +- **Validation rules**: + - `allowed` means the shared surface uses neutral target-scope truth by default. + - `review_required` means the path contains documented provider-owned contextual detail or an allowed exception class. + - `blocked` means a shared surface reintroduced provider-specific default truth. + +## Relationships + +- One `ProviderConnectionSurfaceSummary` consumes exactly one `ProviderConnectionTargetScopeDescriptor` and may carry zero or more `ProviderIdentityContextMetadata` entries beside it. +- One `ProviderConnectionTargetScopeNormalizationResult` may carry zero or more `ProviderIdentityContextMetadata` entries beside exactly one normalized target-scope descriptor. +- One `ProviderConnectionNeutralityCheckResult` references exactly one touched surface or helper path. + +## Lifecycle + +### Target-scope descriptor lifecycle + +- `draft_input`: create or edit flow has neutral shared scope data but is not yet persisted. +- `persisted_shared_truth`: existing `provider_connections` row has a neutral target-scope descriptor available for shared surfaces. +- `context_enriched`: provider-owned contextual details are attached for Microsoft consent, verification, or audit drill-in. + +### Neutrality check lifecycle + +- `allowed`: shared surface is neutral by default. +- `review_required`: shared surface stays neutral but exposes documented provider-owned contextual detail. +- `blocked`: shared surface or helper reintroduced provider-specific default truth. + +## Rollout Model + +- No new database table or column is planned for this slice. +- The descriptor layer is derived from existing provider connection truth and existing provider-owned identity details. +- Provider connection list, detail, create, edit, onboarding provider setup, and audit wording adopt the new descriptor first. +- Broader provider identity migration and compare-boundary work remain out of scope for this feature. \ No newline at end of file diff --git a/specs/238-provider-identity-target-scope/plan.md b/specs/238-provider-identity-target-scope/plan.md new file mode 100644 index 00000000..df4f72f8 --- /dev/null +++ b/specs/238-provider-identity-target-scope/plan.md @@ -0,0 +1,264 @@ +# Implementation Plan: Provider Identity & Target Scope Neutrality + +**Branch**: `238-provider-identity-target-scope` | **Date**: 2026-04-24 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/238-provider-identity-target-scope/spec.md` + +**Note**: This plan keeps the slice intentionally narrow. It introduces one small shared target-scope descriptor layer over the existing provider connection and identity-resolution path, rewrites Microsoft-shaped default labels and summaries only on the in-scope shared surfaces, preserves Entra-specific identity details as contextual provider-owned metadata, and adds focused guardrails so shared provider/platform seams do not regress into Microsoft-first default truth. + +## Summary + +Add a config-free, in-process target-scope descriptor layer that sits between existing provider connection truth and operator-facing shared surfaces. The implementation will harden two concrete hotspots already visible in code: first, the shared resolution objects (`ProviderIdentityResolution` and adjacent provider-connection resolution paths) still expose Microsoft-shaped semantics such as `tenantContext`, `authorityTenant`, and `entra_tenant_id` as if they were default shared truth; second, `ProviderConnectionResource` uses `Entra tenant ID` as a required shared form field and as the default list/detail summary. The plan narrows the shared contract to neutral provider and target-scope concepts, keeps Microsoft tenant and directory identity available only as provider-owned contextual metadata, extends the same neutral semantics into onboarding-adjacent setup, aligns shared audit wording, and proves the result with focused unit, feature, Filament, onboarding, and guard coverage without adding a new provider runtime, new persistence, or a broader schema rewrite. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 +**Primary Dependencies**: `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4 +**Storage**: Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned +**Testing**: Pest v4 unit and focused feature tests through Laravel Sail +**Validation Lanes**: `fast-feedback`, `confidence` +**Target Platform**: Laravel admin web application rendered through Filament on the existing workspace and tenant admin surfaces +**Project Type**: Monorepo with one Laravel runtime in `apps/platform` and spec artifacts at repository root +**Performance Goals**: Keep target-scope resolution deterministic and in-process, add no new outbound call before existing consent or verification paths, and preserve current Microsoft-backed runtime performance on supported flows +**Constraints**: No new provider runtime, no provider marketplace, no new persistence, no broad credential-model redesign, no full rewrite of Spec 137 scope, no new operator-facing shell, and no new Microsoft-shaped default labels on shared surfaces +**Scale/Scope**: One shared target-scope descriptor layer, one bounded contextual-metadata path, three operator-facing surfaces, and focused unit plus feature guard coverage + +## Filament v5 Implementation Contract + +- **Livewire v4.0+ compliance**: Preserved. The feature changes existing Filament resources, shared support code, and tests only, with no legacy Livewire APIs. +- **Provider registration location**: Unchanged. Panel providers remain registered in `apps/platform/bootstrap/providers.php`. +- **Global search coverage**: No new resource or page is added and no existing global-search posture is widened. Provider connection global-search behavior remains unchanged in this slice. +- **Destructive actions**: No new destructive action is added. Existing security-sensitive provider connection mutations continue to require confirmation and authorization on their current surfaces. +- **Asset strategy**: No new assets are planned. Deployment expectations remain unchanged, including `cd apps/platform && php artisan filament:assets` only when registered Filament assets change. +- **Testing plan**: Prove the slice with one shared descriptor unit seam, focused provider connection and onboarding feature coverage, existing audit and UI enforcement coverage extensions, and one bounded neutrality guard test. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces across provider connection list and detail, provider connection create and edit, and the tenant onboarding provider setup step +- **Native vs custom classification summary**: native Filament plus shared support helpers +- **Shared-family relevance**: shared provider connection family, onboarding provider setup family, shared audit wording +- **State layers in scope**: `page`, `detail`, `wizard-step` +- **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`, `manual-smoke` +- **Exception path and spread control**: one named Microsoft contextual-identity boundary for tenant or directory identifiers, consent wording, and verification detail that remain provider-owned instead of shared default truth +- **Active feature PR close-out entry**: `Guardrail` + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, provider connection resolution and mutation services, provider identity resolution, badge and summary rendering, and provider-connection audit wording +- **Shared abstractions reused**: existing provider connection resource and detail path, existing identity and connection resolution services, existing badge renderer domains, existing provider connection audit flows +- **New abstraction introduced? why?**: yes. One small target-scope descriptor and one small surface-summary mapping layer are needed because multiple real surfaces currently duplicate or embed Microsoft-shaped default meaning. +- **Why the existing abstraction was sufficient or insufficient**: the current shared path is sufficient for authorization, persistence, consent, and verification routing, but it is insufficient because the same path uses Microsoft-specific field names and summary language as the default shared contract. +- **Bounded deviation / spread control**: Microsoft tenant, directory, and consent-specific details remain allowed only inside explicitly provider-owned contextual sections, helper copy, and audit detail for the Microsoft provider + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no +- **Central contract reused**: `N/A` +- **Delegated UX behaviors**: `N/A` +- **Surface-owned behavior kept local**: `N/A` +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes +- **Provider-owned seams**: Microsoft-specific consent and verification descriptors, contextual Entra tenant or directory identifiers, platform-app and authority details resolved by existing Microsoft identity services +- **Platform-core seams**: shared provider connection form labels, list and detail summaries, target-scope descriptor layer, shared validation shape, onboarding provider setup summary, audit wording for shared provider connection mutations +- **Neutral platform terms / contracts preserved**: provider, provider connection, target scope, scope identifier, scope display name, consent state, verification state, readiness summary +- **Retained provider-specific semantics and why**: `entra_tenant_id`, `authorityTenant`, `redirectUri`, and Microsoft-specific consent or verification wording remain because current-release truth is still Microsoft-first and operators still need these values on Microsoft-only paths +- **Bounded extraction or follow-up path**: broader governed-subject and compare-boundary work remains `follow-up-spec`; this feature resolves the provider connection identity and target-scope hotspot itself + +## Constitution Check + +*GATE: Passed before Phase 0 research. Re-check after Phase 1 design: still passed with one small descriptor layer, no new persistence, and no new provider runtime.* + +| Gate | Status | Plan Notes | +|------|--------|------------| +| Inventory-first / read-write separation | PASS | The slice changes shared provider connection semantics and existing write-surface wording only. No new snapshot or operational write class is introduced. | +| Single Graph contract path / no inline remote work | PASS | Existing consent and verification flows keep their current provider-owned Graph path. The feature adds no new Graph call and no inline remote work on shared surfaces. | +| RBAC, workspace isolation, tenant isolation | PASS | Existing provider connection and onboarding surfaces keep current workspace and tenant guards. Non-members remain 404 and members missing capability remain 403. | +| Run observability / Ops-UX lifecycle | PASS | No new `OperationRun` type or UX behavior is introduced. Existing health-check or verification runs keep their current service-owned lifecycle. | +| Shared pattern first | PASS | The implementation reuses existing provider connection surfaces and resolution services instead of creating a new provider management stack. | +| Proportionality / no premature abstraction | PASS | One small target-scope descriptor layer is justified by multiple real surfaces and shared audits already depending on the same semantics. No marketplace or second-provider framework is introduced. | +| Persisted truth / behavioral state | PASS | No new table, persisted entity, or lifecycle family is added. Consent and verification remain the existing state dimensions. | +| Provider boundary | PASS | Shared target-scope truth is kept platform-core while Microsoft-specific identity remains provider-owned contextual metadata. | +| Filament v5 / Livewire v4 contract | PASS | The slice uses native Filament forms, tables, infolists, and existing shared primitives only. | +| Test governance | PASS | Coverage stays in focused unit and feature lanes with no browser or heavy-governance expansion. | + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Unit` for the descriptor and neutral-summary mapping seam; `Feature` for provider connection list/detail/create/edit, onboarding provider step, audit wording, and neutrality guard coverage; `Heavy-Governance`: none; `Browser`: none +- **Affected validation lanes**: `fast-feedback`, `confidence` +- **Why this lane mix is the narrowest sufficient proof**: The risk is shared semantic drift and operator-facing meaning on existing admin surfaces, not browser interaction or long-running runtime behavior. Unit tests prove the neutral contract, while feature tests prove the same meaning across the real surfaces and authorization contexts. +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php tests/Unit/Badges/ProviderConnectionBadgesTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- **Fixture / helper / factory / seed / context cost risks**: low to moderate; reuse existing `ProviderConnectionFactory`, workspace and tenant membership fixtures, and current provider connection audit helpers. Do not add a new default provider-world helper. +- **Expensive defaults or shared helper growth introduced?**: no +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: standard native Filament relief for the provider connection resource plus one onboarding wizard-step extension +- **Closing validation and reviewer handoff**: Re-run `pint`, then the focused test commands above. Reviewers should verify that shared labels and required fields are neutral by default, Microsoft contextual details still appear where genuinely needed, audit prose stays aligned, and no generic provider surface regained `Entra tenant ID` as default shared truth. +- **Budget / baseline / trend follow-up**: none expected +- **Review-stop questions**: Did the slice drift into broad identity migration or credential-model redesign? Did any test or helper make Microsoft context implicit by default? Did any shared surface keep Microsoft-shaped default labels or required fields? Did audit or validation copy remain provider-shaped on shared paths? +- **Escalation path**: `document-in-feature` +- **Active feature PR close-out entry**: `Guardrail` +- **Why no dedicated follow-up spec is needed**: This feature resolves the concrete provider-connection target-scope hotspot directly. Later governed-subject and compare-boundary work is already explicitly tracked separately. + +## Project Structure + +### Documentation (this feature) + +```text +specs/238-provider-identity-target-scope/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── provider-identity-target-scope.logical.openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/Workspaces/ManagedTenantOnboardingWizard.php +│ │ └── Resources/ProviderConnectionResource.php +│ ├── Models/ +│ │ └── ProviderConnection.php +│ ├── Services/ +│ │ └── Providers/ +│ │ ├── PlatformProviderIdentityResolver.php +│ │ ├── ProviderConnectionMutationService.php +│ │ ├── ProviderConnectionResolution.php +│ │ ├── ProviderConnectionResolver.php +│ │ ├── ProviderConnectionStateProjector.php +│ │ ├── ProviderIdentityResolution.php +│ │ └── ProviderIdentityResolver.php +│ └── Support/ +│ └── Providers/ +│ └── TargetScope/ +└── tests/ + ├── Feature/ + │ ├── Audit/ + │ ├── Filament/ + │ ├── Guards/ + │ ├── ManagedTenantOnboardingWizardTest.php + │ └── ProviderConnections/ + └── Unit/ + └── Providers/ +``` + +**Structure Decision**: Keep the entire slice inside the existing Laravel runtime in `apps/platform`. The only new code shape planned is a small `Support/Providers/TargetScope` helper namespace or equivalent small support layer. Runtime changes stay inside existing provider connection resources and provider identity or connection services. + +## Complexity Tracking + +No constitutional violation is planned. One bounded complexity addition is tracked because the feature introduces a new shared descriptor layer. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| BLOAT-001 bounded target-scope descriptor | Multiple real surfaces and shared audit copy already need the same neutral provider-connection truth, and the current shared path still exposes Microsoft-shaped default semantics | Page-local label rewrites would not fix the shared contract or stop future regressions on the next surface | + +## Proportionality Review + +- **Current operator problem**: Shared provider connection setup and inspection still teach Microsoft-specific identity as if it were the platform's default connection truth, which causes wrong operator assumptions and future provider-boundary drift. +- **Existing structure is insufficient because**: current resolution objects and resource schemas mix neutral provider-connection meaning with Microsoft-only fields such as `entra_tenant_id`, `tenantContext`, and `authorityTenant`. +- **Narrowest correct implementation**: add one shared target-scope descriptor and summary mapping layer, route shared labels and validation through it, and keep Microsoft-specific detail contextual instead of rewriting the full provider architecture. +- **Ownership cost created**: one small support namespace, one focused unit seam, a handful of extended feature and audit tests, and stricter review expectations on shared provider connection surfaces. +- **Alternative intentionally rejected**: reusing the broader migration and credential redesign scope from Spec 137. It would widen the slice beyond the current hotspot and obscure the real shared-contract goal. +- **Release truth**: current-release truth with bounded anti-drift hardening + +## Phase 0 Research Summary + +- The slice should reuse existing provider connection surfaces and services, not create a new provider-management framework. +- `ProviderConnectionResource` is a concrete hotspot because `entra_tenant_id` is still the required shared form field and a default list or detail summary. +- `ProviderIdentityResolution` is a second hotspot because shared identity output still uses Microsoft-shaped terms such as `tenantContext` and `authorityTenant` as if they were default shared semantics. +- Neutral target-scope truth should live in a small shared descriptor or summary layer used by forms, infolists, tables, onboarding, validation, and audit wording. +- Microsoft tenant, directory, consent, and authority details should remain contextual provider-owned metadata instead of disappearing or becoming the shared default contract. +- Focused unit and feature tests are sufficient; browser or heavy-governance coverage would add cost without proving unique behavior. + +## Phase 1 Design Summary + +- `research.md` captures the bounded contract and vocabulary decisions that keep the slice narrow. +- `data-model.md` defines the target-scope descriptor, provider-owned identity metadata, operator-facing surface summary, and guard result shape. +- `contracts/provider-identity-target-scope.logical.openapi.yaml` defines the internal logical contract for reading normalized target-scope summaries and evaluating neutrality drift. +- `quickstart.md` records the narrow implementation order and the validation sequence. +- `tasks.md` should sequence the work from target-scope descriptor foundation through shared surface adoption, audit wording alignment, and guard coverage. + +## Implementation Close-Out Notes + +- The implemented slice adds `App\Support\Providers\TargetScope` as the bounded target-scope support layer for descriptors, normalization, provider-owned contextual identity metadata, and surface summaries. +- Provider connection create, edit, list, detail, onboarding, identity-resolution, verification, health-check, and shared audit paths now carry neutral `target_scope` truth beside `provider_identity_context` metadata. +- Existing Microsoft runtime truth remains intentionally bounded: `entra_tenant_id` is still the persisted/runtime provider column, while shared operator surfaces use `Target scope` or `Target scope ID` by default and show `Microsoft tenant ID` only as contextual provider-owned detail. +- Unsupported provider and target-scope combinations now fail explicitly through the normalizer and provider connection resolver instead of falling through to Microsoft defaults. +- Existing security-sensitive provider connection actions remain confirmation-gated and capability-gated; this slice adds guard coverage rather than changing the action model. +- Close-out disposition remains `document-in-feature`. Broader governed-subject, compare-boundary, second-provider runtime, and credential-model redesign work stays deferred to follow-up specs. + +## Phase 1 — Agent Context Update + +Run after artifact generation: + +- `.specify/scripts/bash/update-agent-context.sh copilot` + +## Implementation Strategy + +### Phase A — Add the shared target-scope descriptor + +**Goal**: Introduce one neutral shared description of what a provider connection points to. + +| Step | File | Change | +|------|------|--------| +| A.1 | `apps/platform/app/Support/Providers/TargetScope/*` | Add a small target-scope descriptor and summary helper layer that can express `provider`, `scope_kind`, `scope_identifier`, and `scope_display_name` as neutral truth while carrying provider-owned contextual details only in companion metadata or summary shapes. | +| A.2 | `apps/platform/app/Services/Providers/ProviderConnectionResolution.php` and `ProviderIdentityResolution.php` | Stop treating Microsoft-shaped identity output as the default shared semantic contract and expose neutral target-scope data for shared surfaces. | +| A.3 | `apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php` | Prove the descriptor stays neutral by default and still allows bounded Microsoft contextual metadata. | + +### Phase B — Adopt the descriptor on shared provider connection surfaces + +**Goal**: Replace Microsoft-shaped default labels and summaries on the existing shared resource surfaces. + +| Step | File | Change | +|------|------|--------| +| B.1 | `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` | Replace `Entra tenant ID` as the default shared field or summary label with neutral target-scope wording on form, infolist, and table surfaces while keeping Microsoft-specific detail contextual. | +| B.2 | `apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php` or adjacent summary helpers | Align default-visible connection summaries with the same neutral descriptor used by the resource. | +| B.3 | `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php` and existing Filament UI enforcement coverage | Prove list, detail, create, and edit surfaces show neutral target-scope truth first and preserve Microsoft-specific detail only contextually. | + +### Phase C — Extend onboarding and mutation or audit wording + +**Goal**: Keep setup, validation, and audit semantics aligned with the same shared contract. + +| Step | File | Change | +|------|------|--------| +| C.1 | `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` | Reuse the same target-scope descriptor in the onboarding provider setup step so the operator sees the same neutral meaning before continuing. | +| C.2 | `apps/platform/app/Services/Providers/ProviderConnectionMutationService.php` and related audit writers | Align create or update validation messages and audit wording with neutral provider and target-scope vocabulary while preserving provider context. | +| C.3 | `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php` and `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php` | Prove audit entries and onboarding copy use the same neutral target-scope contract. | + +### Phase D — Add guardrails and finish bounded drift protection + +**Goal**: Keep the hotspot closed without widening into a broader provider rewrite. + +| Step | File | Change | +|------|------|--------| +| D.1 | `apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php` | Prove shared identity-resolution output no longer requires Microsoft-shaped default terms. | +| D.2 | `apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php` | Block new Microsoft-specific default labels, filters, validation messages, helper copy, or audit prose on shared provider connection surfaces unless the path is explicitly provider-owned. | +| D.3 | `specs/238-provider-identity-target-scope/quickstart.md` and later `tasks.md` | Keep the neutral-target-scope contract, bounded Microsoft contextual metadata, and no-marketplace/no-second-provider guardrail explicit. | + +## Risks and Mitigations + +- **Scope creep into Spec 137**: Connection-type and credential redesign could get pulled back into this slice. Mitigation: keep the implementation centered on shared target-scope semantics only and leave connection-type migration out of scope. +- **Over-abstracting the descriptor**: A broad multi-provider descriptor framework could appear. Mitigation: keep the new support layer small and only as rich as the currently touched provider connection surfaces require. +- **Audit or validation drift**: Shared UI labels may become neutral while audit prose or validation messages stay Microsoft-shaped. Mitigation: extend existing audit and mutation coverage in the same slice. +- **Hidden Microsoft default leakage**: A later surface might reintroduce `Entra tenant ID` as the default shared label. Mitigation: add one focused guard test for shared provider connection surfaces. +- **Onboarding mismatch**: Provider connection surfaces may become neutral while onboarding still shows Microsoft-first setup meaning. Mitigation: include the onboarding provider step in the first implementation slice and in validation coverage. + +## Post-Design Re-check + +Phase 0 and Phase 1 outputs keep the feature constitution-compliant, Filament v5 and Livewire v4 compliant, and intentionally narrow. The plan introduces no new persistence, no second provider runtime, no provider marketplace workflow, and no broad identity-migration framework. It is ready for `/speckit.tasks`. diff --git a/specs/238-provider-identity-target-scope/quickstart.md b/specs/238-provider-identity-target-scope/quickstart.md new file mode 100644 index 00000000..bbd7b296 --- /dev/null +++ b/specs/238-provider-identity-target-scope/quickstart.md @@ -0,0 +1,82 @@ +# Quickstart: Provider Identity & Target Scope Neutrality + +## Goal + +Implement the shared provider connection target-scope contract so generic provider surfaces stop treating Microsoft identity as the default meaning of a connection. + +## Implementation Sequence + +1. Add the small shared target-scope descriptor and summary helper layer. +2. Refactor shared provider connection and identity-resolution outputs so neutral target-scope truth is available without Microsoft-shaped default labels. +3. Update provider connection list, detail, create, and edit surfaces to use neutral target-scope language by default. +4. Update the onboarding provider setup step and shared audit and validation wording to reuse the same neutral contract. +5. Add focused guardrails that block Microsoft-specific default labels, filters, required fields, validation messages, helper copy, and audit prose from reappearing on shared provider connection surfaces. + +## Suggested Code Areas + +```text +apps/platform/app/Filament/Resources/ProviderConnectionResource.php +apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +apps/platform/app/Services/Providers/ +apps/platform/app/Support/Providers/TargetScope/ +apps/platform/tests/Feature/Audit/ +apps/platform/tests/Feature/Filament/ +apps/platform/tests/Feature/ProviderConnections/ +apps/platform/tests/Feature/Guards/ +apps/platform/tests/Unit/Providers/ +``` + +## Verification Commands + +Run the narrowest shared-contract proof first: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php tests/Unit/Badges/ProviderConnectionBadgesTest.php +``` + +Then run the shared-surface and onboarding proof: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php +``` + +Then run the audit and guardrail proof: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php +``` + +If PHP files changed, finish with formatting: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +## Review Focus + +- Confirm shared provider connection forms, tables, and infolists no longer use `Entra tenant ID` as the default shared label or required field. +- Confirm the shared target-scope descriptor remains understandable without provider-specific vocabulary. +- Confirm unsupported provider or target-scope combinations and missing-context paths fail explicitly instead of inheriting Microsoft defaults. +- Confirm Microsoft tenant, directory, and consent details remain available only as contextual provider-owned metadata. +- Confirm unchanged `404` versus `403` behavior and confirmation-gated sensitive actions are preserved on the touched shared surfaces. +- Confirm onboarding uses the same target-scope meaning as the provider connection resource. +- Confirm audit and validation wording follow the same provider and target-scope vocabulary. +- Confirm no broader credential-model, second-provider, or marketplace scope slipped into the slice. + +## Guardrail Close-Out + +- Validation to complete before final handoff: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php tests/Unit/Badges/ProviderConnectionBadgesTest.php tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- Guardrails checked: + - No new provider runtime or provider marketplace abstraction. + - No new persistence or schema rewrite. + - No Microsoft-specific default labels, filters, required fields, validation messages, helper copy, or audit prose on shared provider connection surfaces. + - Unchanged `404` versus `403` behavior and confirmation-gated sensitive actions remain intact on the touched shared surfaces. + - Microsoft contextual identity remains available where current-release workflows genuinely need it. +- Implemented close-out: + - Shared provider connection surfaces now use `Target scope` vocabulary by default. + - Provider-owned Microsoft details are carried in `provider_identity_context` and diagnostic labels such as `Microsoft tenant ID`. + - Create, update, verification, health-check, and onboarding audit metadata carries `target_scope` plus provider context instead of promoting a raw Microsoft tenant field as shared truth. + - Existing Filament table contracts for provider connections were updated to reflect provider and target scope as default-visible summary columns. +- Close-out decision: `document-in-feature`. The shared provider connection target-scope hotspot is closed here; broader cross-domain provider-boundary work remains separately tracked. diff --git a/specs/238-provider-identity-target-scope/research.md b/specs/238-provider-identity-target-scope/research.md new file mode 100644 index 00000000..7801277e --- /dev/null +++ b/specs/238-provider-identity-target-scope/research.md @@ -0,0 +1,41 @@ +# Research: Provider Identity & Target Scope Neutrality + +## Decision 1: Use one small shared target-scope descriptor instead of a broad provider identity framework + +- **Decision**: Introduce one small shared descriptor for provider connection target scope and reuse it across the provider connection resource, onboarding, validation, and audit wording. +- **Rationale**: The current release needs one neutral shared truth for multiple real surfaces, not a generalized provider marketplace or identity framework. A small descriptor layer is enough to keep shared language neutral while still letting Microsoft-specific detail remain contextual. +- **Alternatives considered**: + - Page-local label cleanup only: rejected because it would leave the shared contract Microsoft-shaped underneath. + - Broad provider identity abstraction: rejected because there is still only one shipped provider runtime and the current hotspot is narrower than that. + +## Decision 2: Keep Microsoft tenant and directory details as provider-owned contextual metadata + +- **Decision**: Retain `entra_tenant_id`, authority-tenant details, consent wording, and Microsoft verification details only as contextual provider-owned metadata on Microsoft paths. +- **Rationale**: Operators still need Microsoft-specific identifiers for consent and troubleshooting, but those identifiers should not define the default meaning of a provider connection on generic shared surfaces. +- **Alternatives considered**: + - Remove Microsoft-specific details from the UI entirely: rejected because the current product still needs them on Microsoft-only workflows. + - Keep them as the default connection summary: rejected because that preserves the current provider-boundary drift. + +## Decision 3: Neutralize shared Filament surfaces first, not every provider term in the repo + +- **Decision**: Limit the first slice to provider connection list, detail, create, edit, onboarding provider setup, and shared audit or validation wording directly tied to those surfaces. +- **Rationale**: These are the concrete operator-facing hotspots already named in the spec. A repo-wide terminology sweep would widen scope without improving the core shared contract any faster. +- **Alternatives considered**: + - Rename every provider-related term immediately: rejected because it would turn one bounded hotspot into a broad copy and architecture sweep. + - Leave onboarding for later: rejected because it would preserve two competing interpretations of the same connection truth. + +## Decision 4: Anchor neutrality in shared resolution and mutation paths, not only in UI labels + +- **Decision**: Update the existing provider connection and identity-resolution outputs plus mutation and audit wording so shared surfaces all consume the same neutral target-scope semantics. +- **Rationale**: UI-only changes would be fragile because validation, audit prose, and future surfaces would still source their meaning from Microsoft-shaped service outputs. +- **Alternatives considered**: + - Keep service outputs unchanged and translate everything in Filament only: rejected because future surfaces would likely repeat the same drift. + - Replace the entire provider identity stack: rejected because the current hotspot is limited to shared target-scope meaning. + +## Decision 5: Enforce the contract with focused guardrails, not browser coverage + +- **Decision**: Add focused unit and feature guard coverage for neutral target-scope descriptors, shared surface labels, onboarding reuse, and audit wording. +- **Rationale**: The risk is semantic drift in shared provider connection truth, not browser-only interaction. Narrow unit and feature coverage are the cheapest proof. +- **Alternatives considered**: + - Browser tests: rejected because they add cost without proving unique behavior for this slice. + - Manual review only: rejected because the feature exists to stop the same hotspot from reopening quietly. \ No newline at end of file diff --git a/specs/238-provider-identity-target-scope/spec.md b/specs/238-provider-identity-target-scope/spec.md new file mode 100644 index 00000000..f930ca12 --- /dev/null +++ b/specs/238-provider-identity-target-scope/spec.md @@ -0,0 +1,310 @@ +# Feature Specification: Provider Identity & Target Scope Neutrality + +**Feature Branch**: `238-provider-identity-target-scope` +**Created**: 2026-04-24 +**Status**: Draft +**Input**: User description: "Promote the next roadmap-fit spec candidate: Provider Identity & Target Scope Neutrality" + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: Shared provider connection semantics still mix neutral platform language with Microsoft-specific tenant identity assumptions, so operators and maintainers cannot tell where platform truth ends and provider detail begins. +- **Today's failure**: Generic-looking provider surfaces and validation paths still imply that a provider connection is fundamentally an Entra tenant binding, which teaches the wrong mental model to operators and quietly deepens provider coupling in shared code. +- **User-visible improvement**: Provider connection create, edit, list, detail, and onboarding-adjacent views describe the connection and its target scope consistently, while Microsoft-specific identity details remain available only when that context is actually needed. +- **Smallest enterprise-capable version**: Neutralize the in-scope provider connection and target-scope contract plus its default UI vocabulary, while preserving current Microsoft onboarding, consent, and verification behavior as bounded provider-specific detail. +- **Architecture center**: The primary deliverable is the shared provider connection target-scope contract. UI vocabulary changes are acceptance evidence for that contract, not the architectural center of the feature. +- **Explicit non-goals**: No second-provider runtime, no provider marketplace, no broad credential model redesign, no replay of the much wider Spec 137 migration scope, no compare-engine changes, and no generic multi-cloud onboarding framework. +- **Permanent complexity imported**: One narrower neutral target-scope contract for shared provider connection truth, one explicit distinction between shared target-scope semantics and provider-owned identity metadata, and focused regression coverage for shared labels, validation, and unsupported-path behavior. +- **Why now**: The roadmap and spec-candidates sequence place this as the next anti-drift hotspot immediately after Spec 237, because leaving provider identity and target scope Microsoft-shaped would undermine the newly hardened boundary before more provider-backed work lands. +- **Why not local**: The problem spans shared persistence semantics, list and detail vocabulary, create and edit flows, onboarding-adjacent copy, and validation behavior. A one-surface copy cleanup would leave the underlying shared contract and other surfaces free to drift again. +- **Approval class**: Core Enterprise +- **Red flags triggered**: Foundation-sounding scope and new boundary vocabulary. Defense: the slice stays bounded to provider connection identity and target-scope semantics, preserves Microsoft-first current product truth, and avoids speculative runtime or marketplace expansion. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitat: 1 | Produktnahe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace, tenant +- **Primary Routes**: + - Existing provider connection collection, create, edit, and detail surfaces under `/admin/provider-connections` + - Existing tenant-scoped onboarding or connection-setup flows under `/admin/t/{tenant}/...` where provider identity and target scope are shown or chosen + - Existing consent, verification, and provider-readiness surfaces that display shared connection identity or target-scope summaries +- **Data Ownership**: + - `provider_connections` remain tenant-owned operational records + - No new tenant-owned or workspace-owned business entity is introduced + - Provider-specific identity metadata remains provider-owned detail attached to existing connection truth rather than a new shared platform object +- **RBAC**: + - Existing workspace membership remains required for all provider connection surfaces + - Existing tenant entitlement remains required on tenant-context surfaces + - Existing provider-connection management capabilities continue to authorize create, edit, verification, and consent-adjacent mutations + - This spec does not introduce a new top-level capability or relax current 404 versus 403 semantics + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: N/A - this slice does not add a new canonical-view surface +- **Explicit entitlement checks preventing cross-tenant leakage**: N/A - in-scope work stays on existing workspace-admin provider-connection surfaces plus existing tenant-scoped onboarding and provider-setup surfaces + +## 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 +- **Interaction class(es)**: shared provider connection vocabulary, detail summaries, form labels, status messaging, and audit prose +- **Systems touched**: provider connection list, detail, create, edit, onboarding-adjacent setup steps, consent and verification summaries, and any shared filters or helper copy that describe connection target scope +- **Existing pattern(s) to extend**: Spec 237 boundary ownership classification, existing provider connection resource and detail surfaces, and the current shared provider connection resolution path +- **Shared contract / presenter / builder / renderer to reuse**: the existing provider connection management surfaces and the current shared provider connection resolution and summary path remain the single shared path; this spec narrows their vocabulary and target-scope semantics rather than introducing a parallel presenter stack +- **Why the existing shared path is sufficient or insufficient**: the current shared path is sufficient for routing, authorization, and current provider-backed behavior, but it is insufficient because it still treats Microsoft-shaped identity and target-scope semantics as the implied default on generic provider surfaces +- **Allowed deviation and why**: bounded provider-specific descriptors are allowed when the selected provider is Microsoft and the operator genuinely needs tenant- or directory-specific context for consent, troubleshooting, or verification +- **Consistency impact**: provider, provider connection, target scope, consent state, and verification state must use one shared vocabulary across form labels, table columns, detail summaries, validation feedback, and audit prose +- **Review focus**: reviewers must block any generic provider connection surface or shared validation path that reintroduces Microsoft-specific field names, required labels, or defaults without explicit provider-owned justification + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: no +- **Shared OperationRun UX contract/layer reused**: N/A +- **Delegated start/completion UX behaviors**: N/A +- **Local surface-owned behavior that remains**: N/A +- **Queued DB-notification policy**: N/A +- **Terminal notification path**: N/A +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: yes +- **Boundary classification**: mixed +- **Seams affected**: shared provider connection semantics, target-scope descriptors, create and edit field vocabulary, list and detail summaries, validation rules, filter labels, consent and verification summaries, and audit wording +- **Neutral platform terms preserved or introduced**: provider, provider connection, target scope, scope identifier, scope display name, consent state, verification state, readiness summary +- **Provider-specific semantics retained and why**: Microsoft tenant ID, directory ID, admin-consent wording, and Microsoft-specific verification details remain contextual provider-owned detail because current-release truth is still Microsoft-first and operators still need those descriptors on Microsoft paths +- **Why this does not deepen provider coupling accidentally**: the shared platform contract is reduced to neutral connection and target-scope truth, while Microsoft-specific identifiers remain contextual metadata instead of required shared platform fields or default operator vocabulary +- **Follow-up path**: follow-up-spec for broader governed-subject and compare-boundary work; the provider connection identity and target-scope hotspot is resolved in-feature + +## 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 | +|---|---|---|---|---|---|---| +| Provider connection list and detail | yes | Native Filament resource and infolist primitives | shared provider connection family | table, detail | no | Default language becomes target-scope neutral; Microsoft descriptors stay contextual | +| Provider connection create and edit | yes | Native Filament forms and sections | shared provider connection family | form, section | no | Existing create and edit flow remains; only shared contract and vocabulary are narrowed | +| Tenant onboarding provider step | yes | Native wizard and action surfaces | shared onboarding and provider family | wizard step | no | Existing onboarding path remains; scope and identity language become neutral by default | + +## 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 | +|---|---|---|---|---|---|---|---| +| Provider connection list and detail | Primary Decision Surface | Decide whether the connection points at the right target scope and what follow-up action is needed | Provider, target scope, consent state, verification state, and readiness summary | Raw provider-specific identifiers, consent diagnostics, low-level verification detail | Primary because this is the operational management surface where operators decide whether the connection is correctly scoped and usable | Follows provider setup and troubleshooting workflow rather than storage internals | Removes the need to infer meaning from raw tenant identifiers or mixed status labels | +| Provider connection create and edit | Primary Decision Surface | Choose the connection target and save it with the correct scope semantics | Provider choice, target-scope summary, required neutral fields, and provider-specific fields only when relevant | Secondary provider-specific guidance and troubleshooting detail | Primary because the operator is defining connection truth here, not just inspecting it later | Follows setup and correction workflow directly | Prevents incorrect scope choices caused by Microsoft-first default wording | +| Tenant onboarding provider step | Primary Decision Surface | Confirm that onboarding is connecting the intended target scope before continuing | Provider, target-scope summary, and the next action needed to continue onboarding | Provider-specific context such as Microsoft tenant identity when selected | Primary because onboarding must answer what is being connected before the operator commits to the next step | Keeps onboarding focused on the current tenant workflow | Reduces ambiguity between platform-neutral setup and Microsoft-specific details | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Provider connection list and detail | List / Table / Bulk | CRUD / List-first Resource | Inspect or edit the connection target scope | Full-row click to detail | allowed | One inline safe shortcut plus More | More or detail header only | /admin/provider-connections | existing provider connection detail route | Workspace, provider, target scope | Provider connections / Provider connection | What scope the connection represents and whether it is consented and verified | none | +| Provider connection create and edit | Record / Detail / Edit | Create/Edit Form | Save the connection with the correct target scope | Explicit create or edit page only | forbidden | Secondary guidance lives in section help text, not competing actions | Existing dangerous lifecycle actions remain off the create page and grouped on detail or edit where already supported | /admin/provider-connections | existing provider connection detail route after save | Provider and target scope | Provider connection | Which target scope will be connected and which fields are provider-neutral versus provider-specific | none | +| Tenant onboarding provider step | Workflow / Wizard / Launch | Wizard / Step-driven Flow | Continue onboarding with the confirmed provider target scope | Existing onboarding step | forbidden | Secondary navigation remains contextual only | Destructive actions are out of scope | existing tenant onboarding route | existing onboarding step route | Workspace, tenant, provider, target scope | Onboarding / Provider setup step | Which scope is being connected before the operator continues | 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Provider connection list and detail | Workspace owner, tenant manager, support operator | Decide whether a provider connection is correctly scoped and ready for use | List and detail | What does this connection point to, and is it ready? | Provider, target scope, consent state, verification state, readiness summary | Raw tenant or directory identifiers, provider-specific verification detail | consent, verification, readiness | TenantPilot only for inspection; Microsoft tenant only when operator triggers existing consent or verification actions | View connection, Edit connection, Retry consent, Run verification | Existing destructive lifecycle actions only where already supported | +| Provider connection create and edit | Workspace owner or tenant manager | Create or correct a provider connection without encoding the wrong target-scope meaning | Create/Edit form | Am I connecting the correct scope, and are these the right fields for this provider? | Provider selection, target-scope summary, neutral required fields, contextual provider-specific fields | Provider-specific troubleshooting guidance | readiness prerequisites only | TenantPilot only until existing provider-facing consent or verification steps are invoked | Save connection, Cancel | none added | +| Tenant onboarding provider step | Tenant manager or onboarding operator | Continue onboarding with the intended provider target scope | Wizard step | What target scope am I connecting right now? | Provider, target-scope summary, current onboarding next step | Provider-specific detail needed for consent or troubleshooting | onboarding progress, consent readiness, verification readiness | TenantPilot onboarding flow and existing provider-facing consent actions | Continue onboarding, Open connection setup where already present | none added | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: yes +- **New enum/state/reason family?**: no +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Operators and maintainers must currently infer shared provider connection meaning from Microsoft-specific labels and identifiers, which creates wrong setup decisions and teaches the wrong platform truth. +- **Existing structure is insufficient because**: Spec 237 classifies seam ownership but does not yet narrow the concrete connection and target-scope contract where Microsoft-shaped semantics are still visible by default. +- **Narrowest correct implementation**: Reuse the existing provider connection surfaces and resolution path, replace only the shared identity and target-scope descriptor/contract semantics, and keep provider-specific detail contextual instead of building a new provider framework. +- **Ownership cost**: The codebase must keep one neutral vocabulary for shared provider connection truth, maintain focused tests that guard default labels and validation behavior, and review provider-specific additions more carefully on shared surfaces. +- **Alternative intentionally rejected**: Reusing the broader old Spec 137 scope was rejected because it mixes identity migration, dedicated credential design, and larger onboarding changes. Pure copy cleanup was rejected because it would leave the shared contract Microsoft-shaped underneath. +- **Release truth**: current-release truth with bounded anti-drift hardening + +### 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**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: the change is proven by shared contract behavior plus operator-visible list, detail, create, edit, and onboarding semantics. Narrow unit coverage proves neutral target-scope mapping and validation, while focused feature coverage proves the operator-visible surfaces and authorization behavior. +- **New or expanded test families**: focused provider connection neutrality guard tests only +- **Fixture / helper cost impact**: low to moderate; tests need existing workspace, tenant, membership, provider connection, and provider-specific metadata fixtures, but no new heavy provider harness or browser stack +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: standard-native-filament +- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient; no browser or heavy-governance lane is justified for this slice +- **Reviewer handoff**: reviewers must confirm that tests prove default neutral target-scope semantics, contextual Microsoft detail retention, unchanged 404 versus 403 behavior, and explicit unsupported-path handling rather than only asserting copy fragments +- **Budget / baseline / trend impact**: none expected beyond a small increase in provider connection feature assertions +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php tests/Unit/Badges/ProviderConnectionBadgesTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Choose the Right Target Scope (Priority: P1) + +As a workspace or tenant admin, I want the provider connection setup flow to describe the target scope in neutral platform language so I can tell what I am connecting before I save it. + +**Why this priority**: This is the main operator-facing trust problem. If setup still implies that every connection is fundamentally an Entra tenant binding, the shared platform contract remains misleading. + +**Independent Test**: Can be fully tested by opening the create or edit flow, choosing an in-scope provider, and confirming that shared fields and summaries use target-scope language while provider-specific fields appear only when the selected provider needs them. + +**Acceptance Scenarios**: + +1. **Given** an operator opens the provider connection create flow, **When** they review the default shared fields, **Then** the form describes provider and target scope in neutral platform language rather than Microsoft-specific defaults. +2. **Given** the selected provider is Microsoft, **When** the operator reaches provider-specific consent or verification context, **Then** the UI may show tenant or directory-specific detail without redefining the shared target-scope meaning. + +--- + +### User Story 2 - Inspect Connection Meaning Without Guesswork (Priority: P1) + +As an operator inspecting existing provider connections, I want list and detail surfaces to show provider, target scope, consent state, and verification state separately so I can understand what the connection represents and what follow-up is needed. + +**Why this priority**: The day-to-day management surface must become trustworthy. If operators still need to infer meaning from raw IDs or mixed status fields, the spec has not delivered its main value. + +**Independent Test**: Can be fully tested by loading the list and detail surfaces for representative provider connections and confirming that the shared summary stays neutral while provider-specific identifiers remain secondary detail. + +**Acceptance Scenarios**: + +1. **Given** a provider connection is listed on the shared resource page, **When** the operator scans the row, **Then** they can see the provider, target scope, consent state, and verification state without reading raw provider-specific IDs. +2. **Given** the operator opens connection detail, **When** provider-specific Microsoft information exists, **Then** it appears as contextual detail rather than the primary shared identity of the connection. + +--- + +### User Story 3 - Extend Shared Provider Surfaces Safely (Priority: P2) + +As a maintainer or reviewer, I want shared provider connection semantics to stay neutral by default so future provider-related changes do not silently reintroduce Microsoft-first platform truth. + +**Why this priority**: The slice is justified not only by current UI clarity but by preventing the same hotspot from reopening as more provider-backed work lands. + +**Independent Test**: Can be fully tested by exercising the focused regression coverage and confirming that a generic provider surface cannot require Microsoft-specific shared fields or default labels without explicit provider-owned justification. + +**Acceptance Scenarios**: + +1. **Given** a generic provider connection surface or shared validation path is extended, **When** Microsoft-specific identity labels are introduced as the default shared contract, **Then** focused regression coverage fails visibly. +2. **Given** a Microsoft-specific surface still needs tenant or directory detail, **When** the same coverage runs, **Then** the contextual provider-owned detail remains allowed without forcing those labels into the generic contract. + +### Edge Cases + +- A provider connection may already have valid target-scope summary data but be missing provider-specific Microsoft identity detail, and the shared surface must stay interpretable without pretending the connection is fully ready. +- A Microsoft connection may legitimately need tenant or directory identifiers for consent or troubleshooting, but those identifiers must not become the default label set on generic provider surfaces. +- Existing rows may still contain legacy Microsoft-shaped fields; in-scope surfaces must present neutral target-scope truth without silently promoting legacy fields back into shared platform defaults. +- Unauthorized users must not learn target-scope or provider identity details through list filters, detail summaries, or onboarding-adjacent helper copy. +- Changing the selected provider in a create or edit flow must update required fields and helper copy without leaving stale Microsoft-specific wording behind. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature does not introduce new Microsoft Graph endpoints, new long-running operations, or new persisted business entities. It narrows the shared contract and UI semantics for existing provider connection create, edit, view, consent, and verification flows. Any in-scope create or update mutation remains server-authorized and auditable. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature adds one narrower shared abstraction for target-scope semantics because the existing shared connection contract still encodes provider-specific truth by default. It does not add new persistence, new state families, or new cross-domain UI taxonomy. + +**Constitution alignment (XCUT-001):** This feature is cross-cutting across provider connection forms, lists, details, onboarding-adjacent setup, validation feedback, and audit prose. It extends the existing shared provider connection path rather than introducing a second presenter or page-local vocabulary layer. + +**Constitution alignment (PROV-001):** Shared provider connection and target-scope truth remain platform-core. Provider-specific identity descriptors, consent wording, and Microsoft verification detail remain provider-owned. The spec keeps provider-specific semantics out of the default shared contract and records broader governed-subject and compare semantics as later follow-up work. + +**Constitution alignment (TEST-GOV-001):** Proof stays in narrow unit and feature lanes. No browser or heavy-governance family is added. Any helper introduced for provider connection neutrality coverage must keep workspace, tenant, membership, and provider context explicit rather than default. + +**Constitution alignment (OPS-UX):** N/A - this slice does not create, start, or redesign an `OperationRun`. + +**Constitution alignment (OPS-UX-START-001):** N/A - no `OperationRun` start or link semantics are changed. + +**Constitution alignment (RBAC-UX):** In-scope surfaces remain on the tenant and workspace admin plane. Non-members or actors lacking tenant entitlement remain 404. Members lacking the relevant capability remain 403. Authorization stays server-side for create, edit, consent-adjacent, and verification mutations. Existing security-sensitive actions remain confirmation-gated and audited. + +**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. + +**Constitution alignment (BADGE-001):** Consent and verification labels remain centralized; this spec does not introduce a new badge family or a second mapping layer. + +**Constitution alignment (UI-FIL-001):** UI-FIL-001 is satisfied. In-scope operator-facing changes stay on native Filament forms, tables, infolists, sections, and existing shared UI primitives. No local replacement markup or page-local status language is introduced. + +**Constitution alignment (UI-NAMING-001):** The target object is the provider connection and its target scope. Operator verbs remain existing verbs such as connect, edit, retry consent, and run verification. The same provider and target-scope vocabulary must be preserved across buttons, headings, helper copy, validation feedback, and audit prose. + +**Constitution alignment (DECIDE-001):** The affected provider connection and onboarding surfaces are primary decision surfaces because they answer what is being connected and whether the connection is correctly scoped. Provider-specific diagnostics remain secondary detail. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The chosen action-surface classes, inspect models, grouped action placement, scope signals, canonical nouns, and default-visible truth are captured in the surface tables above. No exception is required. + +**Constitution alignment (ACTSURF-001 - action hierarchy):** This spec does not add new action families. Existing navigation and mutation placement remain unchanged, and no mixed catch-all action structure is introduced. + +**Constitution alignment (OPSURF-001):** The default-visible content stays operator-first by surfacing provider, target scope, consent state, and verification state before raw provider-specific identifiers. Diagnostics stay secondary and contextual. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature introduces only the minimum shared semantic tightening required to keep provider-specific identity detail from masquerading as platform-core truth. Tests focus on operator-visible meaning and boundary consequences rather than thin presentation indirection alone. + +**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is satisfied. Each affected surface keeps one primary inspect or open model, redundant View actions remain absent, empty action groups remain absent, and destructive actions stay in their existing safe placements. The UI Action Matrix below records the in-scope surfaces. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** In-scope create and edit forms continue to use the existing sectioned Filament layout, view pages continue to use the existing detail or infolist presentation, empty states keep one primary CTA, and tables continue to expose core searchable and filterable dimensions such as provider and target scope. No UX-001 exemption is required. + +### Functional Requirements + +- **FR-238-001**: The shared provider connection contract MUST distinguish neutral provider-connection truth from provider-specific identity metadata. +- **FR-238-002**: In-scope shared create, edit, list, and detail surfaces MUST use neutral default vocabulary for provider connection and target scope. +- **FR-238-003**: The shared provider connection target-scope descriptor/contract MUST remain understandable through neutral fields such as scope kind, scope identifier, and scope display name without assuming Microsoft directory semantics as the default meaning. +- **FR-238-004**: Microsoft-specific tenant, directory, consent, and verification descriptors MAY appear only as contextual provider-owned detail when the selected provider is Microsoft. +- **FR-238-005**: Shared validation and persistence paths MUST NOT require Microsoft-specific fields unless the selected provider explicitly needs them through provider-owned rules. +- **FR-238-006**: Provider connection list and detail surfaces MUST show provider, target scope, consent state, and verification state as separate default-visible dimensions. +- **FR-238-007**: Shared filters, columns, section headings, and detail summaries for provider connections MUST NOT use Microsoft-specific nouns as the default labels. +- **FR-238-008**: Existing Microsoft onboarding, consent, and verification flows MUST preserve the provider-specific detail operators need without redefining the shared target-scope contract. +- **FR-238-009**: Unsupported provider or target-scope combinations encountered on shared paths MUST fail explicitly rather than inheriting Microsoft defaults. +- **FR-238-010**: Existing authorization behavior for provider connection surfaces MUST remain unchanged, including 404 versus 403 semantics. +- **FR-238-011**: Existing security-sensitive provider connection mutations MUST remain confirmation-gated and auditable. +- **FR-238-012**: The first implementation slice MUST cover provider connection list, detail, create, edit, and tenant onboarding-adjacent setup surfaces. +- **FR-238-013**: The feature MUST NOT introduce a second-provider runtime, provider marketplace, or broad credential model redesign. +- **FR-238-014**: Audit events for in-scope provider connection create or update flows MUST describe target scope in neutral vocabulary while still recording provider context. +- **FR-238-015**: Regression coverage MUST fail if a generic provider connection surface reintroduces Microsoft-specific default labels or required shared fields without explicit provider-owned justification. +- **FR-238-016**: Shared provider/platform seams MUST NOT introduce new Microsoft-specific default labels, filter labels, required fields, validation messages, audit prose, or helper copy unless the path is explicitly provider-owned and scoped to the Microsoft provider. +- **FR-238-017**: Any new target-scope helper, descriptor, or normalization path MUST preserve the distinction between neutral platform scope truth and provider-owned identity metadata. + +### Non-Goals + +- Reopening the broader connection-type and credential migration work from Spec 137 +- Adding a second provider or a provider marketplace workflow +- Redesigning provider verification or consent execution logic end to end +- Renaming every provider-related term in the product outside the in-scope shared connection and target-scope hotspot +- Introducing a new governed-subject taxonomy or compare orchestration layer + +### Assumptions + +- Current-release product truth remains Microsoft-first, but shared provider connection semantics must no longer imply that Microsoft identity is the default platform contract. +- Existing provider connection resources, onboarding steps, consent flows, and verification flows remain in place and are narrowed rather than rebuilt. +- Existing operator roles and capabilities already cover the in-scope provider connection surfaces. +- Legacy Microsoft-shaped fields may still exist on some rows, but this spec prefers canonical replacement on in-scope shared surfaces instead of compatibility shims. + +### Dependencies + +- Boundary ownership classification from `specs/237-provider-boundary-hardening/spec.md` +- Existing provider connection resource and detail surfaces +- Existing onboarding, consent, and verification flows that display provider connection identity or scope +- Existing audit logging and authorization infrastructure for provider connections + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Provider connection list | existing provider connection Filament resource | `New connection` | `recordUrl()` to detail | `Edit`, `More` | Existing grouped bulk actions only where already supported | `Connect provider` | `Edit`, `Retry consent`, `Run verification`, `More` | `Save` and `Cancel` on create and edit pages | yes | Default labels become provider and target-scope neutral; no exemption | +| Provider connection detail | existing provider connection detail surface | `Edit`, `Retry consent`, `Run verification` | detail page is the only primary inspect model | none added | none | n/a | `Edit`, `Retry consent`, `Run verification`, `More` | `Save` and `Cancel` on edit page | yes | Provider-specific Microsoft identifiers remain contextual secondary detail | +| Tenant onboarding provider step | existing tenant onboarding provider setup step | none added | existing wizard step only | none | none | existing provider setup CTA remains single primary CTA | contextual open-setup action only where already supported | existing continue and cancel flow remains | yes | Step remains native; only shared target-scope semantics are narrowed | + +### Key Entities *(include if feature involves data)* + +- **Provider Connection**: The tenant-owned record that represents a provider-backed connection and its readiness for use. +- **Target Scope**: The neutral platform description of what the connection points to, expressed through shared fields such as scope kind, scope identifier, and scope display name. +- **Provider-Specific Identity Metadata**: Contextual provider-owned detail such as Microsoft tenant or directory identifiers that operators may need for consent, verification, or troubleshooting. +- **Connection Readiness Summary**: The operator-facing combination of consent state and verification state used to explain whether the connection is usable. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-238-001**: In covered provider connection list, detail, create, edit, and onboarding surfaces, 100% of default shared labels use neutral provider and target-scope language rather than Microsoft-specific defaults. +- **SC-238-002**: In covered Microsoft scenarios, 100% of required tenant or directory detail remains available contextually without becoming the default shared identity language of the connection. +- **SC-238-003**: In focused operator-visible coverage, users can distinguish provider, target scope, consent state, and verification state without relying on raw provider-specific identifiers to infer meaning. +- **SC-238-004**: In all covered unsupported or missing-context scenarios, shared validation paths fail explicitly rather than inheriting Microsoft-first fallback behavior. +- **SC-238-005**: In all covered create, update, and security-sensitive mutation scenarios, the audit trail records workspace, tenant, provider, and target-scope context. +- **SC-238-006**: Focused guard or regression coverage fails when shared provider/platform seams introduce Microsoft-specific default labels, filters, validation messages, audit prose, or helper copy outside explicitly provider-owned Microsoft paths. diff --git a/specs/238-provider-identity-target-scope/tasks.md b/specs/238-provider-identity-target-scope/tasks.md new file mode 100644 index 00000000..6f2322de --- /dev/null +++ b/specs/238-provider-identity-target-scope/tasks.md @@ -0,0 +1,258 @@ +--- + +description: "Task list for Provider Identity & Target Scope Neutrality" + +--- + +# Tasks: Provider Identity & Target Scope Neutrality + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/contracts/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/quickstart.md` + +**Tests**: REQUIRED (Pest) for runtime behavior changes; keep proof in the narrow `Unit` and `Feature` lanes named in the plan +**Operations**: No new `OperationRun` type or Monitoring surface is introduced; preserve current health-check and verification run behavior while neutralizing shared target-scope semantics +**RBAC**: Preserve existing workspace and tenant authorization semantics on touched provider connection and onboarding surfaces, including `404` for non-members and `403` for members missing capability +**Provider Boundary**: Shared target-scope truth remains platform-core; Microsoft tenant, directory, consent, and authority details remain explicitly bounded provider-owned contextual metadata + +**Organization**: Tasks are grouped by user story so the shared target-scope contract, shared-surface adoption, and guardrail close-out can be implemented and validated incrementally. + +## Test Governance Checklist + +- [X] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior. +- [X] New or changed tests stay in existing provider connection, Filament, audit, onboarding, and guard families; no browser or heavy-governance lane is added. +- [X] Shared helpers, factories, fixtures, and provider context defaults stay cheap by default; do not introduce a default provider-world bootstrap. +- [X] Planned validation commands cover descriptor normalization, explicit unsupported-path handling, shared-surface neutrality, unchanged auth and confirmation behavior, onboarding reuse, audit wording, and guardrails without widening scope. +- [X] Surface test profile remains `standard-native-filament` with one onboarding wizard-step extension. +- [X] Any remaining provider-specific hotspot resolves as `document-in-feature` or `follow-up-spec`, not as silent platform-core truth. + +## Phase 1: Setup (Shared Context) + +**Purpose**: Confirm the current hotspots, owning files, and existing proof lanes before introducing the shared target-scope contract. + +- [X] T001 Review the current Microsoft-shaped shared-surface hotspots in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolution.php`, and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [X] T002 [P] Review existing provider connection audit and UI enforcement coverage in `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php` and `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php` +- [X] T003 [P] Review existing provider-owned exception seams in `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php`, and `apps/platform/app/Services/Providers/ProviderConnectionMutationService.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the shared target-scope primitives that every user story depends on. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [X] T004 Create the shared descriptor primitives in `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php` and `apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php` +- [X] T005 [P] Create the shared normalization and surface-summary helpers in `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php` and `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php` +- [X] T006 Update `apps/platform/app/Services/Providers/ProviderConnectionResolution.php` and `apps/platform/app/Services/Providers/ProviderIdentityResolution.php` to expose neutral target-scope data plus provider-owned contextual metadata +- [X] T007 [P] Align resolver consumption of the new neutral descriptor in `apps/platform/app/Services/Providers/ProviderConnectionResolver.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, and `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php` +- [X] T008 [P] Sync the shared descriptor, normalization, surface-summary, and neutrality-evaluation shapes with `specs/238-provider-identity-target-scope/contracts/provider-identity-target-scope.logical.openapi.yaml` + +**Checkpoint**: Shared target-scope primitives exist; user story work can now build on one explicit neutral contract. + +--- + +## Phase 3: User Story 1 - Choose the Right Target Scope (Priority: P1) + +**Goal**: Make the create and edit flow describe provider connection scope in neutral platform language before the operator saves it. + +**Independent Test**: Open the provider connection create or edit flow, choose an in-scope provider, and verify the shared fields and helper copy use neutral target-scope language while Microsoft-specific identity only appears contextually when the selected provider is Microsoft. + +### Tests for User Story 1 + +- [X] T009 [P] [US1] Add descriptor normalization coverage, including explicit unsupported provider or target-scope combinations and missing-context failures, in `apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php` +- [X] T010 [P] [US1] Add create and edit neutral target-scope flow coverage, including explicit unsupported combination and missing-context failures, in `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php` +- [X] T011 [P] [US1] Extend shared authorization and UI enforcement coverage for provider connection create, list, and detail target-scope surfaces, including unchanged `404` versus `403` behavior, in `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php` + +### Implementation for User Story 1 + +- [X] T012 [US1] Replace shared create and edit form fields plus helper text with neutral target-scope wording in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` +- [X] T013 [US1] Route create and edit normalization plus mutation messaging through the shared target-scope helper in `apps/platform/app/Services/Providers/ProviderConnectionMutationService.php` and `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php` +- [X] T014 [US1] Keep Microsoft-specific contextual identity available only on Microsoft create and edit paths in `apps/platform/app/Services/Providers/ProviderIdentityResolver.php` and `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php` +- [X] T015 [US1] Run the US1 proof lane documented in `specs/238-provider-identity-target-scope/quickstart.md` against `apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php`, and `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php` + +**Checkpoint**: User Story 1 is independently deliverable as the core setup-flow neutrality slice. + +--- + +## Phase 4: User Story 2 - Inspect Connection Meaning Without Guesswork (Priority: P1) + +**Goal**: Make list and detail surfaces show provider, target scope, consent state, and verification state separately without forcing operators to infer meaning from raw Microsoft identifiers. + +**Independent Test**: Load provider connection list and detail surfaces for representative connections and verify the default-visible summary stays neutral while provider-specific identity remains secondary diagnostic detail. + +### Tests for User Story 2 + +- [X] T016 [P] [US2] Add list and detail neutrality coverage plus default-visible status separation in `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php` and `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php` +- [X] T017 [P] [US2] Add shared identity-resolution neutrality coverage in `apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php` +- [X] T018 [P] [US2] Extend badge and summary assertions that keep consent and verification distinct in `apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php` and `apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php` + +### Implementation for User Story 2 + +- [X] T019 [US2] Replace default list, table, infolist, and detail target-scope labels plus summaries in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` +- [X] T020 [P] [US2] Align derived connection summaries with the shared target-scope descriptor in `apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php` and `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php` +- [X] T021 [US2] Keep contextual Microsoft identity and diagnostics secondary to neutral target-scope truth in `apps/platform/app/Services/Providers/ProviderConnectionResolution.php` and `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` +- [X] T022 [US2] Run the US2 proof lane documented in `specs/238-provider-identity-target-scope/quickstart.md` against `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php`, `apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php`, `apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php`, and `apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php` + +**Checkpoint**: User Stories 1 and 2 both work independently, with shared setup and inspection surfaces aligned to one neutral target-scope contract. + +--- + +## Phase 5: User Story 3 - Extend Shared Provider Surfaces Safely (Priority: P2) + +**Goal**: Reuse the same neutral target-scope contract in onboarding, mutation copy, and audit wording, and block future regressions on shared provider surfaces. + +**Independent Test**: Exercise onboarding, audit, UI enforcement, and guard coverage to verify shared provider surfaces cannot reintroduce Microsoft-specific default labels, filters, required fields, validation messages, helper copy, or audit prose without explicit provider-owned justification. + +### Tests for User Story 3 + +- [X] T023 [P] [US3] Add onboarding provider-setup neutrality coverage plus unchanged `404` versus `403` leakage protection in `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php` +- [X] T024 [P] [US3] Extend shared audit wording coverage and sensitive-action confirmation-gate coverage in `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php` and `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php` +- [X] T025 [P] [US3] Add shared-surface guard coverage for Microsoft-specific default labels, filters, required fields, validation messages, helper copy, and audit prose in `apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php` + +### Implementation for User Story 3 + +- [X] T026 [US3] Reuse the shared target-scope descriptor in the onboarding provider setup step in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [X] T027 [US3] Align provider connection action labels, filter labels, section headings, helper copy, validation messages, mutation messaging, and audit wording with neutral provider and target-scope vocabulary in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` and `apps/platform/app/Services/Providers/ProviderConnectionMutationService.php` +- [X] T028 [US3] Codify the provider-owned contextual exception boundary from `specs/237-provider-boundary-hardening/spec.md` and the review-stop expectations in `specs/238-provider-identity-target-scope/contracts/provider-identity-target-scope.logical.openapi.yaml` and `specs/238-provider-identity-target-scope/quickstart.md` +- [X] T029 [US3] Run the US3 proof lane documented in `specs/238-provider-identity-target-scope/quickstart.md` against `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php`, `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php`, `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php`, and `apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php` + +**Checkpoint**: All user stories are independently functional, and shared provider surfaces are guarded against reintroducing Microsoft-first default truth. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finalize formatting, validation, and guardrail close-out across the full slice. + +- [X] T030 [P] Refresh the implementation notes, logical contract wording, and validation commands in `specs/238-provider-identity-target-scope/plan.md`, `specs/238-provider-identity-target-scope/quickstart.md`, and `specs/238-provider-identity-target-scope/contracts/provider-identity-target-scope.logical.openapi.yaml` +- [X] T031 [P] Run formatting for touched PHP files in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/app/Services/Providers/`, `apps/platform/app/Support/Providers/TargetScope/`, `apps/platform/tests/Unit/Providers/`, and `apps/platform/tests/Feature/` +- [X] T032 Run the final focused validation lane from `specs/238-provider-identity-target-scope/quickstart.md` against `apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php`, `apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php`, `apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php`, `apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php`, `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php`, `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php`, `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php`, and `apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php` +- [X] T033 Record the guardrail close-out, `document-in-feature` disposition, and deferred provider-boundary follow-up status in `specs/238-provider-identity-target-scope/plan.md` and `specs/238-provider-identity-target-scope/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies; can start immediately. +- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user stories. +- **User Story 1 (Phase 3)**: Depends on Foundational completion. +- **User Story 2 (Phase 4)**: Depends on Foundational completion and should follow the stable descriptor contract from User Story 1 on shared resource surfaces. +- **User Story 3 (Phase 5)**: Depends on Foundational completion and should follow User Story 1 because onboarding and audit wording reuse the same target-scope descriptor and mutation vocabulary. +- **Polish (Phase 6)**: Depends on all desired user stories being complete. + +### User Story Dependencies + +- **User Story 1 (P1)**: This is the MVP and should ship first. +- **User Story 2 (P1)**: Conceptually independent after Phase 2, but it reuses the target-scope descriptor and shared resource semantics stabilized in User Story 1. +- **User Story 3 (P2)**: Conceptually independent after Phase 2, but its onboarding, audit, confirmation-gate, exception-boundary, and guardrail tasks remain part of the shippable slice because they satisfy FR-238-011, FR-238-014, FR-238-015, and FR-238-016. + +### Within Each User Story + +- Tests should be written and fail before the corresponding implementation tasks. +- Shared descriptor and resolution changes must land before any surface-specific adoption task consumes them. +- Serialize edits in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` when multiple story tasks touch the same resource file. +- Finish each story's verification task before moving to the next priority when working sequentially. + +### Parallel Opportunities + +- **Setup**: `T002` and `T003` can run in parallel. +- **Foundational**: `T005`, `T007`, and `T008` can run in parallel after `T004` defines the descriptor primitives. +- **US1 tests**: `T009`, `T010`, and `T011` can run in parallel. +- **US1 implementation**: `T013` and `T014` can run in parallel after `T012` settles the shared form contract. +- **US2 tests**: `T016`, `T017`, and `T018` can run in parallel. +- **US2 implementation**: `T020` can run in parallel with `T021`, but both should follow `T019` because `ProviderConnectionResource.php` is shared. +- **US3 tests**: `T023`, `T024`, and `T025` can run in parallel. +- **US3 implementation**: `T026` and `T028` can run in parallel; `T027` should serialize after `T019` and `T012` because it touches `ProviderConnectionResource.php`. +- **Polish**: `T030` and `T031` can run in parallel before `T032` and `T033`. + +--- + +## Parallel Example: User Story 1 + +```bash +# Run US1 coverage in parallel: +T009 apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php +T010 apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php +T011 apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php + +# Then split the non-overlapping implementation follow-up: +T013 apps/platform/app/Services/Providers/ProviderConnectionMutationService.php and apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php +T014 apps/platform/app/Services/Providers/ProviderIdentityResolver.php and apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php +``` + +--- + +## Parallel Example: User Story 2 + +```bash +# Run US2 summary and identity coverage in parallel: +T016 apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php and apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php +T017 apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php +T018 apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php and apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php + +# Then split non-overlapping implementation follow-up: +T020 apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php and apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php +T021 apps/platform/app/Services/Providers/ProviderConnectionResolution.php +``` + +--- + +## Parallel Example: User Story 3 + +```bash +# Run US3 onboarding, audit, and guard coverage in parallel: +T023 apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php +T024 apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php +T025 apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php + +# Then split non-overlapping implementation follow-up: +T026 apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +T028 specs/238-provider-identity-target-scope/contracts/provider-identity-target-scope.logical.openapi.yaml and specs/238-provider-identity-target-scope/quickstart.md +``` + +--- + +## Implementation Strategy + +### MVP / Shippable Slice + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1 and Phase 4: User Story 2. +4. Complete all of Phase 5: User Story 3 so onboarding, audit wording, confirmation-gate proof, exception-boundary codification, and guard coverage land with the same shippable slice. +5. Stop and validate with `T015`, `T022`, `T029`, and `T032`. +6. Review whether list, detail, create, edit, onboarding, audit wording, and guardrails now expose one stable target-scope contract before moving to close-out only work. + +### Incremental Delivery + +1. Setup and Foundational establish the shared target-scope descriptor and neutral resolution path. +2. Add User Story 1 and validate create and edit neutrality. +3. Add User Story 2 and validate inspection, summary, and status separation parity. +4. Add User Story 3 and validate onboarding reuse, audit wording, and guardrail protection. +5. Finish with formatting, final validation, and close-out documentation. + +### Parallel Team Strategy + +With multiple developers: + +1. Complete Setup and Foundational together. +2. After Phase 2: + - Developer A: create and edit neutrality in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` plus supporting mutation wiring. + - Developer B: list and detail summary alignment in `apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php` and the new target-scope support files. + - Developer C: onboarding, audit wording, and guardrails in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php`, and `apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php`. +3. Serialize edits in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` because User Stories 1, 2, and 3 all touch that file. + +### Suggested MVP Scope + +The narrowest shippable increment is Phase 1 through Phase 5. Phase 6 is close-out, formatting, and final documentation refresh. + +--- + +## Notes + +- `[P]` marks tasks that can run in parallel once their prerequisites are satisfied and the files do not overlap. +- `[US1]`, `[US2]`, and `[US3]` map directly to the user stories in `spec.md`. +- The narrowest proving lane remains `fast-feedback` plus `confidence`; do not widen into browser or heavy-governance without explicit follow-up justification. +- Keep Microsoft-specific identity contextual and bounded to provider-owned surfaces; do not turn this slice into a second-provider or credential-model redesign. -- 2.45.2 From 58f9bb73550d31ef57ad6cddb1d412f58f17cc04 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sat, 25 Apr 2026 09:13:54 +0000 Subject: [PATCH 12/36] chore: commit all workspace changes (#275) Auto-generated PR: commit all workspace changes (includes .github/skills addition). Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/275 --- .../spec-kit-next-best-one-shot/SKILL.md | 383 ++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 .github/skills/spec-kit-next-best-one-shot/SKILL.md diff --git a/.github/skills/spec-kit-next-best-one-shot/SKILL.md b/.github/skills/spec-kit-next-best-one-shot/SKILL.md new file mode 100644 index 00000000..602c9025 --- /dev/null +++ b/.github/skills/spec-kit-next-best-one-shot/SKILL.md @@ -0,0 +1,383 @@ +--- +name: spec-kit-next-best-one-shot +description: Select the most suitable next TenantPilot/TenantAtlas spec from roadmap and spec-candidates, then create Spec Kit preparation artifacts in one pass: spec.md, plan.md, and tasks.md. Use when the user wants the agent to choose the next best spec based on roadmap fit, current candidates, repository state, platform priorities, governance foundations, UX improvements, architecture cleanup, or implementation readiness. This skill must not implement application code. +--- + +# Skill: Spec Kit Next-Best One-Shot Preparation + +## Purpose + +Use this skill when the user wants the agent to select the most suitable next spec from existing product planning sources and then create the Spec Kit preparation artifacts in one pass: + +1. choose the next best spec candidate +2. create `spec.md` +3. create `plan.md` +4. create `tasks.md` +5. provide a manual analysis prompt + +This skill prepares implementation work, but it must not perform implementation. + +The intended workflow is: + +```text +roadmap.md + spec-candidates.md +→ select next best spec +→ one-shot spec + plan + tasks preparation +→ manual repo-based analysis/review +→ explicit implementation step later +``` + +## When to Use + +Use this skill when the user asks things like: + +```text +Nimm die nächste sinnvollste Spec aus roadmap und spec-candidates. +``` + +```text +Wähle die nächste geeignete Spec und erstelle spec, plan und tasks. +``` + +```text +Schau in roadmap.md und spec-candidates.md und mach daraus die nächste Spec. +``` + +```text +Such die beste nächste Spec aus und bereite sie in einem Rutsch vor. +``` + +```text +Nimm angesichts Roadmap und Spec Candidates das sinnvollste nächste Thema. +``` + +## Hard Rules + +- Work strictly repo-based. +- Do not implement application code. +- Do not modify production code. +- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task. +- Do not execute implementation commands. +- Do not run destructive commands. +- Do not invent roadmap priorities not supported by repository documents. +- Do not pick a spec only because it is listed first. +- Do not select broad platform rewrites when a smaller dependency-unlocking spec is more appropriate. +- Prefer specs that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers. +- Prefer small, reviewable, implementation-ready specs over large ambiguous themes. +- Preserve TenantPilot/TenantAtlas terminology. +- Follow the repository constitution and existing Spec Kit conventions. +- If repository truth conflicts with roadmap/candidate wording, keep repository truth and document the deviation. +- If no candidate is suitable, create no spec and explain why. + +## Required Repository Checks + +Before selecting the next spec, inspect: + +1. `.specify/memory/constitution.md` +2. `.specify/templates/` +3. `specs/` +4. `docs/product/spec-candidates.md` +5. roadmap documents under `docs/product/`, especially `roadmap.md` if present +6. nearby existing specs related to top candidate areas +7. current spec numbering conventions +8. application code only as needed to verify whether a candidate is already done, blocked, duplicated, or technically mis-scoped + +Do not edit application code. + +## Candidate Selection Criteria + +Evaluate candidate specs using these criteria. + +### 1. Roadmap Fit + +Prefer candidates that directly support the current roadmap sequence or unlock the next roadmap layer. + +Examples: + +- governance foundations before advanced compliance views +- evidence/snapshot foundations before auditor packs +- control catalog foundations before CIS/NIS2 mappings +- decision/workflow surfaces before autonomous governance +- provider/platform boundary cleanup before multi-provider expansion + +### 2. Foundation Value + +Prefer candidates that strengthen reusable platform foundations: + +- RBAC and workspace/tenant isolation +- auditability +- evidence and snapshot truth +- operation observability +- provider boundary neutrality +- canonical vocabulary +- baseline/control/finding semantics +- enterprise detail-page or decision-surface patterns + +### 3. Dependency Unblocking + +Prefer specs that unblock multiple later candidates. + +A good next spec should usually make future specs smaller, safer, or more consistent. + +### 4. Scope Size + +Prefer a candidate that can be implemented as a narrow, testable slice. + +Avoid selecting: + +- broad platform rewrites +- vague product themes +- multi-feature bundles +- speculative future-provider frameworks +- large UX redesigns without a clear first slice + +### 5. Repo Readiness + +Prefer candidates where the repository already has enough structure to implement the next slice safely. + +Check whether related models, services, UI pages, tests, or concepts already exist. + +### 6. Risk Reduction + +Prefer candidates that reduce current architectural or product risk: + +- legacy dual-world semantics +- unclear truth ownership +- inconsistent operator UX +- missing audit/evidence boundaries +- repeated manual workflow friction +- false-positive calmness in governance surfaces + +### 7. User/Product Value + +Prefer specs that produce visible operator value or make the platform more sellable without creating heavy scope. + +## Candidate Selection Output + +Before creating files, prepare a concise decision summary for the final response. + +The selected candidate should include: + +- selected candidate title +- why it was selected +- why the nearest alternatives were not selected now +- roadmap relationship +- expected implementation slice + +Do not create multiple specs unless the repository convention explicitly supports it and the user asked for it. + +## Selection Matrix + +When comparing candidates, use a small matrix internally or in the final summary: + +| Candidate | Roadmap fit | Foundation value | Scope size | Repo readiness | Risk reduction | Decision | +|---|---:|---:|---:|---:|---:|---| + +Keep it concise. Do not over-analyze if the best candidate is obvious. + +## Spec Directory Rules + +Create a new spec directory using the next valid spec number and a kebab-case slug: + +```text +specs/-/ +``` + +The exact number must be derived from the current repository state and existing numbering conventions. + +Create or update only these preparation artifacts inside the selected spec directory: + +```text +specs/-/spec.md +specs/-/plan.md +specs/-/tasks.md +``` + +If the repository templates require additional preparation files, create them only when consistent with existing Spec Kit conventions. + +Do not create implementation files. + +## `spec.md` Requirements + +The spec must be product- and behavior-oriented. + +Include: + +- Feature title +- Selected-candidate rationale +- Problem statement +- Business/product value +- Roadmap relationship +- Primary users/operators +- User stories +- Functional requirements +- Non-functional requirements +- UX requirements +- RBAC/security requirements +- Auditability/observability requirements +- Data/truth-source requirements where relevant +- Out of scope +- Acceptance criteria +- Success criteria +- Risks +- Assumptions +- Open questions +- Follow-up spec candidates if the selected candidate had to be narrowed + +TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles: + +- workspace/tenant isolation +- capability-first RBAC +- auditability +- operation/result truth separation +- source-of-truth clarity +- calm enterprise operator UX +- progressive disclosure where useful +- no false positive calmness +- provider/platform boundary clarity where relevant +- versioned governance semantics where relevant + +## `plan.md` Requirements + +The plan must be repo-aware and implementation-oriented, but must not implement. + +Include: + +- Technical approach +- Existing repository surfaces likely affected +- Domain/model implications +- UI/Filament implications +- Livewire implications where relevant +- OperationRun/monitoring implications where relevant +- RBAC/policy implications +- Audit/logging/evidence implications where relevant +- Data/migration implications where relevant +- Test strategy +- Rollout considerations +- Risk controls +- Implementation phases + +Where relevant, clearly distinguish: + +- execution truth +- artifact truth +- backup/snapshot truth +- evidence truth +- recovery confidence +- operator next action + +Use those distinctions only when relevant to the selected spec. + +## `tasks.md` Requirements + +Tasks must be ordered, small, and verifiable. + +Include: + +- checkbox tasks +- phase grouping +- tests before or alongside implementation tasks where practical +- final validation tasks +- documentation/update tasks if needed +- explicit non-goals where useful + +Avoid vague tasks such as: + +```text +Clean up code +Refactor UI +Improve performance +Make it enterprise-ready +``` + +Prefer concrete tasks such as: + +```text +- [ ] Add a feature test covering workspace isolation for . +- [ ] Update to display . +- [ ] Add policy coverage for . +``` + +If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths. + +## Scope Control + +If the selected roadmap/candidate item is too broad, narrow it into the smallest valuable first implementation slice. + +Add a `Follow-up spec candidates` section for deferred concerns. + +Examples of follow-up candidates: + +- assigned findings +- pending approvals +- personal work queue +- notification delivery settings +- evidence pack export hardening +- operation monitoring refinements +- autonomous governance decision surfaces +- compliance mapping library expansion +- MSP portfolio rollups +- provider-specific adapters + +Do not force follow-up candidates into the primary spec. + +## Final Response Requirements + +After creating or updating the artifacts, respond with: + +1. Selected candidate +2. Why this candidate was selected +3. Why close alternatives were deferred +4. Created or updated spec directory +5. Files created or updated +6. Important repo-based adjustments made +7. Assumptions made +8. Open questions, if any +9. Recommended next manual analysis prompt +10. Explicit statement that no implementation was performed + +Keep the response concise, but include enough detail for the user to continue immediately. + +## Required Next Manual Analysis Prompt + +Always provide a ready-to-copy prompt like this, adapted to the created spec number and slug: + +```markdown +Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer. + +Analysiere die neu erstellte Spec `-` streng repo-basiert. + +Ziel: +Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar, roadmap-konform und constitution-konform sind. + +Wichtig: +- Keine Implementierung. +- Keine Codeänderungen. +- Keine Scope-Erweiterung. +- Prüfe nur gegen Repo-Wahrheit. +- Prüfe auch, ob die ausgewählte Spec wirklich die sinnvollste nächste Spec aus `docs/product/spec-candidates.md` und `docs/product/roadmap.md` war. +- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs. +- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor. +- Wenn alles passt, gib eine klare Implementierungsfreigabe. +``` + +## Example Invocation + +User: + +```text +Nutze den Skill spec-kit-next-best-one-shot. +Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec und erstelle spec, plan und tasks in einem Rutsch. +Keine Implementierung. +``` + +Expected behavior: + +1. Inspect constitution, templates, specs, roadmap, and spec candidates. +2. Compare candidate suitability. +3. Select the next best candidate. +4. Determine the next valid spec number. +5. Create `spec.md`, `plan.md`, and `tasks.md`. +6. Keep scope tight. +7. Do not implement. +8. Return selection rationale, artifact summary, and next manual analysis prompt. \ No newline at end of file -- 2.45.2 From fb32e9bfa5ace9e4e25bec01323c826aa44f3142 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sat, 25 Apr 2026 18:11:23 +0000 Subject: [PATCH 13/36] feat: canonical operation type source of truth (#276) ## Summary - implement the canonical operation type source-of-truth slice across operation writers, monitoring surfaces, onboarding flows, and supporting services - add focused contract and regression coverage for canonical operation type handling - include the generated spec 239 artifacts for the feature slice ## Validation - browser smoke PASS for `/admin` -> workspace overview -> operations -> operation detail -> tenant-scoped operations drilldown - spec/plan/tasks/quickstart artifact analysis cleaned up to a no-findings state - automated test suite not run in this session Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/276 --- .github/agents/copilot-instructions.md | 4 +- .../spec-kit-next-best-one-shot/SKILL.md | 367 +++++------ ...TenantpilotDispatchDirectoryGroupsSync.php | 3 +- .../TenantpilotPurgeNonPersistentData.php | 5 +- ...otReconcileBackupScheduleOperationRuns.php | 4 +- .../Filament/Pages/BaselineCompareLanding.php | 3 +- .../Filament/Pages/BaselineCompareMatrix.php | 5 +- .../app/Filament/Pages/InventoryCoverage.php | 3 +- .../TenantlessOperationRunViewer.php | 8 +- .../ManagedTenantOnboardingWizard.php | 20 +- .../Resources/BackupScheduleResource.php | 9 +- .../Resources/BaselineProfileResource.php | 4 +- .../Pages/ViewBaselineProfile.php | 5 +- .../Pages/ListInventoryItems.php | 3 +- .../Resources/OperationRunResource.php | 11 +- .../Resources/ProviderConnectionResource.php | 3 +- .../Widgets/Inventory/InventoryKpiHeader.php | 4 +- .../Jobs/ApplyBackupScheduleRetentionJob.php | 10 +- apps/platform/app/Models/BackupSchedule.php | 12 +- apps/platform/app/Models/OperationRun.php | 22 +- .../Directory/EntraGroupSyncService.php | 3 +- .../Directory/RoleDefinitionsSyncService.php | 3 +- .../Sources/OperationsSummarySource.php | 1 + .../Inventory/InventoryMissingService.php | 4 +- .../Inventory/InventorySyncService.php | 4 +- .../Onboarding/OnboardingLifecycleService.php | 5 +- .../app/Services/OperationRunService.php | 2 +- .../QueuedExecutionLegitimacyGate.php | 16 +- .../Providers/ProviderOperationRegistry.php | 24 +- ...indingsLifecycleBackfillRunbookService.php | 2 +- .../OperationRunTriageService.php | 22 +- .../Baselines/BaselineCompareStats.php | 5 +- .../platform/app/Support/OperationCatalog.php | 55 +- .../app/Support/OperationRunLinks.php | 25 +- .../platform/app/Support/OperationRunType.php | 37 +- .../Operations/OperationLifecyclePolicy.php | 4 +- .../OperationRunCapabilityResolver.php | 17 +- .../Support/OpsUx/OperationUxPresenter.php | 2 +- apps/platform/config/tenantpilot.php | 20 +- .../PlatformVocabularyBoundaryGuardTest.php | 31 +- .../ApplyRetentionJobTest.php | 2 +- .../DispatchIdempotencyTest.php | 10 +- .../RunNowRetryActionsTest.php | 22 +- .../TenantpilotPurgeNonPersistentDataTest.php | 4 +- .../ProviderBackedDirectoryStartTest.php | 2 +- .../ScheduledSyncDispatchTest.php | 2 +- .../StartSyncFromGroupsPageTest.php | 2 +- .../Filament/OperationRunListFiltersTest.php | 27 + .../OperationRunLinkContractGuardTest.php | 8 + .../ProviderDispatchGateCoverageTest.php | 4 +- .../Inventory/InventorySyncButtonTest.php | 18 +- .../Inventory/InventorySyncServiceTest.php | 4 +- .../InventorySyncStartSurfaceTest.php | 2 +- .../ManagedTenantOnboardingWizardTest.php | 34 +- .../AuditCoverageOperationsTest.php | 26 +- .../QueuedExecutionContractMatrixTest.php | 8 +- ...entorySyncExecutionReauthorizationTest.php | 2 +- .../OpsUx/UnknownOperationTypeLabelTest.php | 6 + .../ProviderDispatchGateStartSurfaceTest.php | 2 +- .../ProviderOperationConcurrencyTest.php | 8 +- ...nagedTenantOnboardingProviderStartTest.php | 6 +- .../OnboardingDraftResolverTest.php | 35 ++ .../QueuedExecutionLegitimacyGateTest.php | 2 +- ...iderOperationRegistryCanonicalTypeTest.php | 47 ++ .../ProviderOperationStartGateTest.php | 30 +- .../OperationRunTypeCanonicalContractTest.php | 45 ++ .../Support/OperationTypeResolutionTest.php | 25 +- docs/product/roadmap.md | 118 +++- docs/product/spec-candidates.md | 571 +++++++++++++++++- .../checklists/requirements.md | 35 ++ ...-type-source-of-truth.logical.openapi.yaml | 276 +++++++++ .../data-model.md | 190 ++++++ .../plan.md | 289 +++++++++ .../quickstart.md | 127 ++++ .../research.md | 47 ++ .../spec.md | 359 +++++++++++ .../tasks.md | 242 ++++++++ 77 files changed, 2994 insertions(+), 430 deletions(-) create mode 100644 apps/platform/tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php create mode 100644 apps/platform/tests/Unit/Support/OperationRunTypeCanonicalContractTest.php create mode 100644 specs/239-canonical-operation-type-source-of-truth/checklists/requirements.md create mode 100644 specs/239-canonical-operation-type-source-of-truth/contracts/canonical-operation-type-source-of-truth.logical.openapi.yaml create mode 100644 specs/239-canonical-operation-type-source-of-truth/data-model.md create mode 100644 specs/239-canonical-operation-type-source-of-truth/plan.md create mode 100644 specs/239-canonical-operation-type-source-of-truth/quickstart.md create mode 100644 specs/239-canonical-operation-type-source-of-truth/research.md create mode 100644 specs/239-canonical-operation-type-source-of-truth/spec.md create mode 100644 specs/239-canonical-operation-type-source-of-truth/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 4541e6e6..a5e4affa 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -254,6 +254,8 @@ ## Active Technologies - Existing PostgreSQL tables such as `provider_connections` and `operation_runs`; one new in-repo config catalog for provider-boundary ownership; no new database tables (237-provider-boundary-hardening) - PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4 (238-provider-identity-target-scope) - Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned (238-provider-identity-target-scope) +- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4 (239-canonical-operation-type-source-of-truth) +- PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables (239-canonical-operation-type-source-of-truth) - PHP 8.4.15 (feat/005-bulk-operations) @@ -288,9 +290,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 239-canonical-operation-type-source-of-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4 - 238-provider-identity-target-scope: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4 - 237-provider-boundary-hardening: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4 -- 236-canonical-control-catalog-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing governance domain models and builders, existing Evidence Snapshot and Tenant Review infrastructure ### Pre-production compatibility check diff --git a/.github/skills/spec-kit-next-best-one-shot/SKILL.md b/.github/skills/spec-kit-next-best-one-shot/SKILL.md index 602c9025..59c91d9f 100644 --- a/.github/skills/spec-kit-next-best-one-shot/SKILL.md +++ b/.github/skills/spec-kit-next-best-one-shot/SKILL.md @@ -1,29 +1,35 @@ --- name: spec-kit-next-best-one-shot -description: Select the most suitable next TenantPilot/TenantAtlas spec from roadmap and spec-candidates, then create Spec Kit preparation artifacts in one pass: spec.md, plan.md, and tasks.md. Use when the user wants the agent to choose the next best spec based on roadmap fit, current candidates, repository state, platform priorities, governance foundations, UX improvements, architecture cleanup, or implementation readiness. This skill must not implement application code. +description: Select the most suitable next TenantPilot/TenantAtlas spec from roadmap and spec-candidates, then run the GitHub Spec Kit preparation flow in one pass: specify, plan, tasks, and analyze. Use when the user wants the agent to choose the next best spec, execute the real Spec Kit workflow including branch/spec-directory mechanics, analyze the generated artifacts, and fix preparation issues before implementation. This skill must not implement application code. --- # Skill: Spec Kit Next-Best One-Shot Preparation ## Purpose -Use this skill when the user wants the agent to select the most suitable next spec from existing product planning sources and then create the Spec Kit preparation artifacts in one pass: +Use this skill when the user wants the agent to select the most suitable next spec from existing product planning sources and then execute the real GitHub Spec Kit preparation flow in one pass: -1. choose the next best spec candidate -2. create `spec.md` -3. create `plan.md` -4. create `tasks.md` -5. provide a manual analysis prompt +1. select the next best spec candidate from roadmap and spec candidates +2. run the repository's Spec Kit `specify` flow for that selected candidate +3. run the repository's Spec Kit `plan` flow for the generated spec +4. run the repository's Spec Kit `tasks` flow for the generated plan +5. run the repository's Spec Kit `analyze` flow against the generated artifacts +6. fix issues in Spec Kit preparation artifacts only (`spec.md`, `plan.md`, `tasks.md`, and related Spec Kit metadata if required) +7. stop before implementation +8. provide a concise readiness summary for the user -This skill prepares implementation work, but it must not perform implementation. +This skill must use the repository's actual Spec Kit scripts, commands, templates, branch naming rules, and generated paths. It must not manually bypass Spec Kit by creating arbitrary spec folders or files. The only allowed fixes after `analyze` are preparation-artifact fixes, not application-code implementation. The intended workflow is: ```text roadmap.md + spec-candidates.md → select next best spec -→ one-shot spec + plan + tasks preparation -→ manual repo-based analysis/review +→ run Spec Kit specify +→ run Spec Kit plan +→ run Spec Kit tasks +→ run Spec Kit analyze +→ fix preparation-artifact issues → explicit implementation step later ``` @@ -32,28 +38,36 @@ ## When to Use Use this skill when the user asks things like: ```text -Nimm die nächste sinnvollste Spec aus roadmap und spec-candidates. +Nimm die nächste sinnvollste Spec aus roadmap und spec-candidates und führe specify, plan, tasks und analyze aus. ``` ```text -Wähle die nächste geeignete Spec und erstelle spec, plan und tasks. +Wähle die nächste geeignete Spec und mach den Spec-Kit-Flow inklusive analyze in einem Rutsch. ``` ```text -Schau in roadmap.md und spec-candidates.md und mach daraus die nächste Spec. +Schau in roadmap.md und spec-candidates.md und starte daraus specify, plan, tasks und analyze. ``` ```text -Such die beste nächste Spec aus und bereite sie in einem Rutsch vor. +Such die beste nächste Spec aus und bereite sie per GitHub Spec Kit vollständig vor. ``` ```text -Nimm angesichts Roadmap und Spec Candidates das sinnvollste nächste Thema. +Nimm angesichts Roadmap und Spec Candidates das sinnvollste nächste Thema, aber nicht implementieren. ``` ## Hard Rules - Work strictly repo-based. +- Use the repository's actual GitHub Spec Kit workflow. +- Do not manually invent spec numbers, branch names, or spec paths if Spec Kit provides a script or command for that. +- Do not manually create `spec.md`, `plan.md`, or `tasks.md` when the Spec Kit workflow can generate them. +- Do not bypass Spec Kit branch mechanics. +- Run `analyze` after `tasks` when the repository supports it. +- Fix only issues found in Spec Kit preparation artifacts and planning metadata. +- Do not treat analyze findings as permission to implement product code. +- If analyze reports implementation work as missing, record it in `tasks.md` instead of implementing it. - Do not implement application code. - Do not modify production code. - Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task. @@ -67,23 +81,39 @@ ## Hard Rules - Preserve TenantPilot/TenantAtlas terminology. - Follow the repository constitution and existing Spec Kit conventions. - If repository truth conflicts with roadmap/candidate wording, keep repository truth and document the deviation. -- If no candidate is suitable, create no spec and explain why. +- If no candidate is suitable, do not run Spec Kit commands and explain why. -## Required Repository Checks +## Required Repository Checks Before Selection Before selecting the next spec, inspect: 1. `.specify/memory/constitution.md` 2. `.specify/templates/` -3. `specs/` -4. `docs/product/spec-candidates.md` -5. roadmap documents under `docs/product/`, especially `roadmap.md` if present -6. nearby existing specs related to top candidate areas -7. current spec numbering conventions -8. application code only as needed to verify whether a candidate is already done, blocked, duplicated, or technically mis-scoped +3. `.specify/scripts/` +4. existing Spec Kit command usage or repository instructions, if present +5. `specs/` +6. `docs/product/spec-candidates.md` +7. roadmap documents under `docs/product/`, especially `roadmap.md` if present +8. nearby existing specs related to top candidate areas +9. current branch and git status +10. application code only as needed to verify whether a candidate is already done, blocked, duplicated, or technically mis-scoped Do not edit application code. +## Git and Branch Safety + +Before running any Spec Kit command or script: + +1. Check the current branch. +2. Check whether the working tree is clean. +3. If there are unrelated uncommitted changes, stop and report them. Do not continue. +4. If the working tree only contains user-intended planning edits for this operation, continue cautiously. +5. Let Spec Kit create or switch to the correct feature branch when that is how the repository workflow works. +6. Do not force checkout, reset, stash, rebase, merge, or delete branches. +7. Do not overwrite existing specs. + +If the repo requires an explicit branch creation script for `specify`, use that script rather than manually creating the branch. + ## Candidate Selection Criteria Evaluate candidate specs using these criteria. @@ -152,213 +182,192 @@ ### 7. User/Product Value Prefer specs that produce visible operator value or make the platform more sellable without creating heavy scope. -## Candidate Selection Output +## Required Selection Output Before Spec Kit Execution -Before creating files, prepare a concise decision summary for the final response. - -The selected candidate should include: +Before running the Spec Kit flow, identify: - selected candidate title +- source location in roadmap/spec-candidates - why it was selected -- why the nearest alternatives were not selected now +- why close alternatives were deferred - roadmap relationship -- expected implementation slice +- smallest viable implementation slice +- proposed concise feature description to feed into `specify` -Do not create multiple specs unless the repository convention explicitly supports it and the user asked for it. +The feature description must be product- and behavior-oriented. It should not be a low-level implementation plan. -## Selection Matrix +## Spec Kit Execution Flow -When comparing candidates, use a small matrix internally or in the final summary: +After selecting the candidate, execute the real repository Spec Kit preparation sequence, including analysis and preparation-artifact fixes. -| Candidate | Roadmap fit | Foundation value | Scope size | Repo readiness | Risk reduction | Decision | -|---|---:|---:|---:|---:|---:|---| +### Step 1: Determine the repository's Spec Kit command pattern -Keep it concise. Do not over-analyze if the best candidate is obvious. +Inspect repository instructions and scripts to identify how this repo expects Spec Kit to be run. -## Spec Directory Rules - -Create a new spec directory using the next valid spec number and a kebab-case slug: +Common locations to inspect: ```text -specs/-/ +.specify/scripts/ +.specify/templates/ +.specify/memory/constitution.md +.github/prompts/ +.github/skills/ +README.md +specs/ ``` -The exact number must be derived from the current repository state and existing numbering conventions. +Use the repo-specific mechanism if present. -Create or update only these preparation artifacts inside the selected spec directory: +### Step 2: Run `specify` -```text -specs/-/spec.md -specs/-/plan.md -specs/-/tasks.md -``` +Run the repository's `specify` flow using the selected candidate and the smallest viable slice. -If the repository templates require additional preparation files, create them only when consistent with existing Spec Kit conventions. +The `specify` input should include: -Do not create implementation files. +- selected candidate title +- problem statement +- operator/user value +- roadmap relationship +- out-of-scope boundaries +- key acceptance criteria +- important enterprise constraints -## `spec.md` Requirements +Let Spec Kit create the correct branch and spec location if that is the repo's configured behavior. -The spec must be product- and behavior-oriented. +### Step 3: Run `plan` -Include: +Run the repository's `plan` flow for the generated spec. -- Feature title -- Selected-candidate rationale -- Problem statement -- Business/product value -- Roadmap relationship -- Primary users/operators -- User stories -- Functional requirements -- Non-functional requirements -- UX requirements -- RBAC/security requirements -- Auditability/observability requirements -- Data/truth-source requirements where relevant -- Out of scope -- Acceptance criteria -- Success criteria -- Risks -- Assumptions -- Open questions -- Follow-up spec candidates if the selected candidate had to be narrowed - -TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles: +The `plan` input should keep the scope tight and should require repo-based alignment with: +- constitution +- existing architecture - workspace/tenant isolation -- capability-first RBAC -- auditability -- operation/result truth separation -- source-of-truth clarity -- calm enterprise operator UX -- progressive disclosure where useful -- no false positive calmness -- provider/platform boundary clarity where relevant -- versioned governance semantics where relevant +- RBAC +- OperationRun/observability where relevant +- evidence/snapshot/truth semantics where relevant +- Filament/Livewire conventions where relevant +- test strategy -## `plan.md` Requirements +### Step 4: Run `tasks` -The plan must be repo-aware and implementation-oriented, but must not implement. +Run the repository's `tasks` flow for the generated plan. -Include: +The generated tasks must be: -- Technical approach -- Existing repository surfaces likely affected -- Domain/model implications -- UI/Filament implications -- Livewire implications where relevant -- OperationRun/monitoring implications where relevant -- RBAC/policy implications -- Audit/logging/evidence implications where relevant -- Data/migration implications where relevant -- Test strategy -- Rollout considerations -- Risk controls -- Implementation phases +- ordered +- small +- testable +- grouped by phase +- limited to the selected scope +- suitable for later manual analysis before implementation -Where relevant, clearly distinguish: +### Step 5: Run `analyze` -- execution truth -- artifact truth -- backup/snapshot truth -- evidence truth -- recovery confidence -- operator next action +Run the repository's `analyze` flow against the generated Spec Kit artifacts. -Use those distinctions only when relevant to the selected spec. +Analyze must check: -## `tasks.md` Requirements +- consistency between `spec.md`, `plan.md`, and `tasks.md` +- constitution alignment +- roadmap alignment +- whether the selected candidate was narrowed safely +- whether tasks are complete enough for implementation +- whether tasks accidentally require scope not described in the spec +- whether plan details conflict with repository architecture or terminology +- whether implementation risks are documented instead of silently ignored -Tasks must be ordered, small, and verifiable. +Do not use analyze as a trigger to implement application code. -Include: +### Step 6: Fix preparation-artifact issues only -- checkbox tasks -- phase grouping -- tests before or alongside implementation tasks where practical -- final validation tasks -- documentation/update tasks if needed -- explicit non-goals where useful +If analyze finds issues, fix only Spec Kit preparation artifacts such as: -Avoid vague tasks such as: +- `spec.md` +- `plan.md` +- `tasks.md` +- generated Spec Kit metadata files, if the repository uses them -```text -Clean up code -Refactor UI -Improve performance -Make it enterprise-ready -``` +Allowed fixes include: -Prefer concrete tasks such as: +- clarify requirements +- tighten scope +- move out-of-scope work into follow-up candidates +- correct terminology +- add missing tasks +- remove tasks not backed by the spec +- align plan language with repository architecture +- add missing acceptance criteria or validation tasks -```text -- [ ] Add a feature test covering workspace isolation for . -- [ ] Update to display . -- [ ] Add policy coverage for . -``` +Forbidden fixes include: -If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths. +- modifying application code +- creating migrations +- editing models, services, jobs, policies, Filament resources, Livewire components, tests, or commands +- running implementation or test-fix loops +- changing runtime behavior -## Scope Control +### Step 7: Stop -If the selected roadmap/candidate item is too broad, narrow it into the smallest valuable first implementation slice. +After `analyze` has passed or preparation-artifact issues have been fixed, stop. -Add a `Follow-up spec candidates` section for deferred concerns. +Do not implement. +Do not modify application code. +Do not run implementation tests unless the repository's Spec Kit preparation command requires a non-destructive validation. -Examples of follow-up candidates: +## Failure Handling -- assigned findings -- pending approvals -- personal work queue -- notification delivery settings -- evidence pack export hardening -- operation monitoring refinements -- autonomous governance decision surfaces -- compliance mapping library expansion -- MSP portfolio rollups -- provider-specific adapters +If a Spec Kit command or analyze phase fails: -Do not force follow-up candidates into the primary spec. +1. Stop immediately. +2. Report the failing command or phase. +3. Summarize the error. +4. Do not attempt implementation as a workaround. +5. Suggest the smallest safe next action. + +If the branch or working tree state is unsafe: + +1. Stop before running Spec Kit commands. +2. Report the current branch and relevant uncommitted files. +3. Ask the user to commit, stash, or move to a clean worktree. ## Final Response Requirements -After creating or updating the artifacts, respond with: +After the Spec Kit preparation flow completes, respond with: 1. Selected candidate 2. Why this candidate was selected 3. Why close alternatives were deferred -4. Created or updated spec directory -5. Files created or updated -6. Important repo-based adjustments made -7. Assumptions made -8. Open questions, if any -9. Recommended next manual analysis prompt -10. Explicit statement that no implementation was performed +4. Current branch after Spec Kit execution +5. Generated spec path +6. Files created or updated by Spec Kit +7. Analyze result summary +8. Preparation-artifact fixes applied after analyze +9. Assumptions made +10. Open questions, if any +11. Recommended next implementation prompt +12. Explicit statement that no application implementation was performed Keep the response concise, but include enough detail for the user to continue immediately. -## Required Next Manual Analysis Prompt +## Required Next Implementation Prompt -Always provide a ready-to-copy prompt like this, adapted to the created spec number and slug: +Always provide a ready-to-copy implementation prompt like this, adapted to the generated spec branch/path, but only after analyze has passed or preparation-artifact issues have been fixed: ```markdown -Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer. +Du bist ein Senior Staff Software Engineer für TenantPilot/TenantAtlas. -Analysiere die neu erstellte Spec `-` streng repo-basiert. - -Ziel: -Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar, roadmap-konform und constitution-konform sind. +Implementiere die vorbereitete Spec `` streng anhand von `tasks.md`. Wichtig: -- Keine Implementierung. -- Keine Codeänderungen. +- Arbeite task-sequenziell. +- Ändere nur Dateien, die für die jeweilige Task notwendig sind. +- Halte dich an `spec.md`, `plan.md`, `tasks.md` und die Constitution. - Keine Scope-Erweiterung. -- Prüfe nur gegen Repo-Wahrheit. -- Prüfe auch, ob die ausgewählte Spec wirklich die sinnvollste nächste Spec aus `docs/product/spec-candidates.md` und `docs/product/roadmap.md` war. -- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs. -- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor. -- Wenn alles passt, gib eine klare Implementierungsfreigabe. +- Keine Opportunistic Refactors. +- Führe passende Tests nach sinnvollen Task-Gruppen aus. +- Wenn eine Task unklar oder falsch ist, stoppe und dokumentiere den Konflikt statt frei zu improvisieren. +- Am Ende liefere geänderte Dateien, Teststatus, offene Risiken und nicht erledigte Tasks. ``` ## Example Invocation @@ -367,17 +376,23 @@ ## Example Invocation ```text Nutze den Skill spec-kit-next-best-one-shot. -Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec und erstelle spec, plan und tasks in einem Rutsch. -Keine Implementierung. +Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec. +Führe danach GitHub Spec Kit specify, plan, tasks und analyze in einem Rutsch aus. +Behebe alle analyze-Issues in den Spec-Kit-Artefakten. +Keine Application-Implementierung. ``` Expected behavior: -1. Inspect constitution, templates, specs, roadmap, and spec candidates. -2. Compare candidate suitability. -3. Select the next best candidate. -4. Determine the next valid spec number. -5. Create `spec.md`, `plan.md`, and `tasks.md`. -6. Keep scope tight. -7. Do not implement. -8. Return selection rationale, artifact summary, and next manual analysis prompt. \ No newline at end of file +1. Inspect constitution, Spec Kit scripts/templates, specs, roadmap, and spec candidates. +2. Check branch and working tree safety. +3. Compare candidate suitability. +4. Select the next best candidate. +5. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup. +6. Run the repository's real Spec Kit `plan` flow. +7. Run the repository's real Spec Kit `tasks` flow. +8. Run the repository's real Spec Kit `analyze` flow. +9. Fix analyze issues only in Spec Kit preparation artifacts. +10. Stop before application implementation. +11. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, and next implementation prompt. +``` \ No newline at end of file diff --git a/apps/platform/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php b/apps/platform/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php index 8c0a69b6..c4cf3672 100644 --- a/apps/platform/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php +++ b/apps/platform/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php @@ -4,6 +4,7 @@ use App\Models\Tenant; use App\Services\OperationRunService; +use App\Support\OperationRunType; use Carbon\CarbonImmutable; use Illuminate\Console\Command; @@ -50,7 +51,7 @@ public function handle(): int $opService = app(OperationRunService::class); $opRun = $opService->ensureRunWithIdentityStrict( tenant: $tenant, - type: 'entra_group_sync', + type: OperationRunType::DirectoryGroupsSync->value, identityInputs: [ 'selection_key' => $selectionKey, 'slot_key' => $slotKey, diff --git a/apps/platform/app/Console/Commands/TenantpilotPurgeNonPersistentData.php b/apps/platform/app/Console/Commands/TenantpilotPurgeNonPersistentData.php index e316097f..93097419 100644 --- a/apps/platform/app/Console/Commands/TenantpilotPurgeNonPersistentData.php +++ b/apps/platform/app/Console/Commands/TenantpilotPurgeNonPersistentData.php @@ -11,6 +11,7 @@ use App\Models\PolicyVersion; use App\Models\RestoreRun; use App\Models\Tenant; +use App\Support\OperationRunType; use Illuminate\Console\Command; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; @@ -168,12 +169,12 @@ private function recordPurgeOperationRun(Tenant $tenant, array $counts): void 'tenant_id' => (int) $tenant->id, 'user_id' => null, 'initiator_name' => 'System', - 'type' => 'backup_schedule_purge', + 'type' => OperationRunType::BackupSchedulePurge->value, 'status' => 'completed', 'outcome' => 'succeeded', 'run_identity_hash' => hash('sha256', implode(':', [ (string) $tenant->id, - 'backup_schedule_purge', + OperationRunType::BackupSchedulePurge->value, now()->toISOString(), Str::uuid()->toString(), ])), diff --git a/apps/platform/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php b/apps/platform/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php index 5d9f5c63..bd0185ab 100644 --- a/apps/platform/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php +++ b/apps/platform/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php @@ -7,7 +7,9 @@ use App\Models\Tenant; use App\Services\OperationRunService; use App\Services\Operations\OperationLifecycleReconciler; +use App\Support\OperationCatalog; use App\Support\OperationRunOutcome; +use App\Support\OperationRunType; use Illuminate\Console\Command; class TenantpilotReconcileBackupScheduleOperationRuns extends Command @@ -28,7 +30,7 @@ public function handle( $dryRun = (bool) $this->option('dry-run'); $query = OperationRun::query() - ->where('type', 'backup_schedule_run') + ->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value)) ->whereIn('status', ['queued', 'running']); if ($olderThanMinutes > 0) { diff --git a/apps/platform/app/Filament/Pages/BaselineCompareLanding.php b/apps/platform/app/Filament/Pages/BaselineCompareLanding.php index 58175f2f..1188d374 100644 --- a/apps/platform/app/Filament/Pages/BaselineCompareLanding.php +++ b/apps/platform/app/Filament/Pages/BaselineCompareLanding.php @@ -21,6 +21,7 @@ use App\Support\Baselines\TenantGovernanceAggregate; use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\OperationRunLinks; +use App\Support\OperationRunType; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\ReasonTranslation\ReasonPresenter; @@ -489,7 +490,7 @@ private function compareNowAction(): Action OpsUxBrowserEvents::dispatchRunEnqueued($this); - OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare') + OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : OperationRunType::BaselineCompare->value) ->actions($run instanceof OperationRun ? [ Action::make('view_run') ->label('Open operation') diff --git a/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php b/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php index 84ec9306..8f5b0e8c 100644 --- a/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php +++ b/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php @@ -18,6 +18,7 @@ use App\Support\Baselines\Compare\CompareStrategyRegistry; use App\Support\Navigation\CanonicalNavigationContext; use App\Support\OperationRunLinks; +use App\Support\OperationRunType; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\WorkspaceUiEnforcement; @@ -810,8 +811,8 @@ private function compareAssignedTenants(): void OpsUxBrowserEvents::dispatchRunEnqueued($this); $toast = (int) $result['queuedCount'] > 0 - ? OperationUxPresenter::queuedToast('baseline_compare') - : OperationUxPresenter::alreadyQueuedToast('baseline_compare'); + ? OperationUxPresenter::queuedToast(OperationRunType::BaselineCompare->value) + : OperationUxPresenter::alreadyQueuedToast(OperationRunType::BaselineCompare->value); $toast ->body($summary.' Open Operations for progress and next steps.') diff --git a/apps/platform/app/Filament/Pages/InventoryCoverage.php b/apps/platform/app/Filament/Pages/InventoryCoverage.php index 537cb3a0..ba1f8a7e 100644 --- a/apps/platform/app/Filament/Pages/InventoryCoverage.php +++ b/apps/platform/app/Filament/Pages/InventoryCoverage.php @@ -21,6 +21,7 @@ use App\Support\Inventory\TenantCoverageTruth; use App\Support\Inventory\TenantCoverageTruthResolver; use App\Support\OperationRunLinks; +use App\Support\OperationRunType; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; @@ -561,6 +562,6 @@ protected function coverageTruth(): ?TenantCoverageTruth private function inventorySyncHistoryUrl(Tenant $tenant): string { - return OperationRunLinks::index($tenant, operationType: 'inventory_sync'); + return OperationRunLinks::index($tenant, operationType: OperationRunType::InventorySync->value); } } diff --git a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index 1223aa74..0afd761f 100644 --- a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -16,7 +16,9 @@ use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\RelatedNavigationResolver; use App\Support\OperateHub\OperateHubShell; +use App\Support\OperationCatalog; use App\Support\OperationRunLinks; +use App\Support\OperationRunType; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\RunDetailPolling; @@ -507,12 +509,14 @@ private function canResumeCapture(): bool return false; } - if (! in_array((string) $this->run->type, ['baseline_capture', 'baseline_compare'], true)) { + $canonicalType = OperationCatalog::canonicalCode((string) $this->run->type); + + if (! in_array($canonicalType, [OperationRunType::BaselineCapture->value, OperationRunType::BaselineCompare->value], true)) { return false; } $context = is_array($this->run->context) ? $this->run->context : []; - $tokenKey = (string) $this->run->type === 'baseline_capture' + $tokenKey = $canonicalType === OperationRunType::BaselineCapture->value ? 'baseline_capture.resume_token' : 'baseline_compare.resume_token'; $token = data_get($context, $tokenKey); diff --git a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index 51b7b184..2915116d 100644 --- a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -42,6 +42,7 @@ use App\Support\Onboarding\OnboardingCheckpoint; use App\Support\Onboarding\OnboardingDraftStage; use App\Support\Onboarding\OnboardingLifecycleState; +use App\Support\OperationCatalog; use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; @@ -767,7 +768,7 @@ private function normalizeBootstrapOperationTypes(array $operationTypes): array foreach ($operationTypes as $key => $value) { if (is_string($value)) { - $normalizedValue = trim($value); + $normalizedValue = $this->normalizeBootstrapOperationType($value); if ($normalizedValue !== '' && in_array($normalizedValue, $supportedTypes, true)) { $normalized[] = $normalizedValue; @@ -787,7 +788,7 @@ private function normalizeBootstrapOperationTypes(array $operationTypes): array default => false, }; - $normalizedKey = trim($key); + $normalizedKey = $this->normalizeBootstrapOperationType($key); if ($isSelected && in_array($normalizedKey, $supportedTypes, true)) { $normalized[] = $normalizedKey; @@ -797,13 +798,24 @@ private function normalizeBootstrapOperationTypes(array $operationTypes): array return array_values(array_unique($normalized)); } + private function normalizeBootstrapOperationType(string $operationType): string + { + $operationType = trim($operationType); + + if ($operationType === '') { + return ''; + } + + return OperationCatalog::canonicalCode($operationType); + } + /** * @return array */ private function supportedBootstrapCapabilities(): array { return [ - 'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC, + 'inventory.sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC, 'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC, ]; } @@ -3343,7 +3355,7 @@ private function dispatchBootstrapJob( OperationRun $run, ): void { match ($operationType) { - 'inventory_sync' => ProviderInventorySyncJob::dispatch( + 'inventory.sync' => ProviderInventorySyncJob::dispatch( tenantId: $tenantId, userId: $userId, providerConnectionId: $providerConnectionId, diff --git a/apps/platform/app/Filament/Resources/BackupScheduleResource.php b/apps/platform/app/Filament/Resources/BackupScheduleResource.php index d0275eff..a900b52a 100644 --- a/apps/platform/app/Filament/Resources/BackupScheduleResource.php +++ b/apps/platform/app/Filament/Resources/BackupScheduleResource.php @@ -25,6 +25,7 @@ use App\Support\Filament\TablePaginationProfiles; use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; +use App\Support\OperationRunType; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; @@ -457,7 +458,7 @@ public static function table(Table $table): Table $nonce = (string) Str::uuid(); $operationRun = $operationRunService->ensureRunWithIdentity( tenant: $tenant, - type: 'backup_schedule_run', + type: OperationRunType::BackupScheduleExecute->value, identityInputs: [ 'backup_schedule_id' => (int) $record->getKey(), 'nonce' => $nonce, @@ -528,7 +529,7 @@ public static function table(Table $table): Table $nonce = (string) Str::uuid(); $operationRun = $operationRunService->ensureRunWithIdentity( tenant: $tenant, - type: 'backup_schedule_run', + type: OperationRunType::BackupScheduleExecute->value, identityInputs: [ 'backup_schedule_id' => (int) $record->getKey(), 'nonce' => $nonce, @@ -755,7 +756,7 @@ public static function table(Table $table): Table $nonce = (string) Str::uuid(); $operationRun = $operationRunService->ensureRunWithIdentity( tenant: $tenant, - type: 'backup_schedule_run', + type: OperationRunType::BackupScheduleExecute->value, identityInputs: [ 'backup_schedule_id' => (int) $record->getKey(), 'nonce' => $nonce, @@ -852,7 +853,7 @@ public static function table(Table $table): Table $nonce = (string) Str::uuid(); $operationRun = $operationRunService->ensureRunWithIdentity( tenant: $tenant, - type: 'backup_schedule_run', + type: OperationRunType::BackupScheduleExecute->value, identityInputs: [ 'backup_schedule_id' => (int) $record->getKey(), 'nonce' => $nonce, diff --git a/apps/platform/app/Filament/Resources/BaselineProfileResource.php b/apps/platform/app/Filament/Resources/BaselineProfileResource.php index 28d38a76..a367debf 100644 --- a/apps/platform/app/Filament/Resources/BaselineProfileResource.php +++ b/apps/platform/app/Filament/Resources/BaselineProfileResource.php @@ -32,6 +32,8 @@ use App\Support\Navigation\CrossResourceNavigationMatrix; use App\Support\Navigation\RelatedContextEntry; use App\Support\Navigation\RelatedNavigationResolver; +use App\Support\OperationCatalog; +use App\Support\OperationRunType; use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonResolutionEnvelope; @@ -873,7 +875,7 @@ private static function latestBaselineCaptureEnvelope(BaselineProfile $profile): { $run = OperationRun::query() ->where('workspace_id', (int) $profile->workspace_id) - ->where('type', 'baseline_capture') + ->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCapture->value)) ->where('context->baseline_profile_id', (int) $profile->getKey()) ->where('status', 'completed') ->orderByDesc('completed_at') diff --git a/apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php b/apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php index 98fef481..86a53c4e 100644 --- a/apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php +++ b/apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php @@ -17,6 +17,7 @@ use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineReasonCodes; use App\Support\OperationRunLinks; +use App\Support\OperationRunType; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\ReasonTranslation\ReasonPresenter; @@ -340,8 +341,8 @@ private function compareAssignedTenantsAction(): Action OpsUxBrowserEvents::dispatchRunEnqueued($this); $toast = (int) $result['queuedCount'] > 0 - ? OperationUxPresenter::queuedToast('baseline_compare') - : OperationUxPresenter::alreadyQueuedToast('baseline_compare'); + ? OperationUxPresenter::queuedToast(OperationRunType::BaselineCompare->value) + : OperationUxPresenter::alreadyQueuedToast(OperationRunType::BaselineCompare->value); $toast ->body($summary.' Open Operations for progress and next steps.') diff --git a/apps/platform/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php b/apps/platform/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php index 8f4d870a..6a8da46c 100644 --- a/apps/platform/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php +++ b/apps/platform/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -15,6 +15,7 @@ use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\OperationRunLinks; +use App\Support\OperationRunType; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; @@ -175,7 +176,7 @@ protected function getHeaderActions(): array $opService = app(OperationRunService::class); $opRun = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'inventory_sync', + type: OperationRunType::InventorySync->value, identityInputs: [ 'selection_hash' => $computed['selection_hash'], ], diff --git a/apps/platform/app/Filament/Resources/OperationRunResource.php b/apps/platform/app/Filament/Resources/OperationRunResource.php index 5d412cb4..e3cf6469 100644 --- a/apps/platform/app/Filament/Resources/OperationRunResource.php +++ b/apps/platform/app/Filament/Resources/OperationRunResource.php @@ -27,6 +27,7 @@ use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\OperationRunType; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\RunDurationInsights; use App\Support\OpsUx\SummaryCountsNormalizer; @@ -230,7 +231,9 @@ public static function table(Table $table): Table return $query; } - return $query->whereIn('type', OperationCatalog::rawValuesForCanonical($value)); + return $query->whereIn('type', OperationCatalog::rawValuesForCanonical( + OperationCatalog::canonicalCode($value), + )); }), Tables\Filters\SelectFilter::make('status') ->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())), @@ -411,7 +414,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support ); } - if ((string) $record->type === 'baseline_compare') { + if (OperationCatalog::canonicalCode((string) $record->type) === OperationRunType::BaselineCompare->value) { $baselineCompareFacts = static::baselineCompareFacts($record, $factory); $baselineCompareEvidence = static::baselineCompareEvidencePayload($record); $gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record); @@ -466,7 +469,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support } } - if ((string) $record->type === 'baseline_capture') { + if (OperationCatalog::canonicalCode((string) $record->type) === OperationRunType::BaselineCapture->value) { $baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record); if ($baselineCaptureEvidence !== []) { @@ -1446,7 +1449,7 @@ private static function reconciliationPayload(OperationRun $record): array */ private static function inventorySyncCoverageSection(OperationRun $record): ?array { - if ((string) $record->type !== 'inventory_sync') { + if (OperationCatalog::canonicalCode((string) $record->type) !== OperationRunType::InventorySync->value) { return null; } diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php index 66c07a82..7abe627e 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php @@ -20,6 +20,7 @@ use App\Support\Badges\BadgeRenderer; use App\Support\Filament\TablePaginationProfiles; use App\Support\OperationRunLinks; +use App\Support\OperationRunType; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -949,7 +950,7 @@ public static function makeInventorySyncAction(): Actions\Action static::handleProviderOperationAction( record: $record, gate: $gate, - operationType: 'inventory_sync', + operationType: OperationRunType::InventorySync->value, blockedTitle: 'Inventory sync blocked', dispatcher: function (Tenant $tenant, User $user, ProviderConnection $connection, OperationRun $operationRun): void { ProviderInventorySyncJob::dispatch( diff --git a/apps/platform/app/Filament/Widgets/Inventory/InventoryKpiHeader.php b/apps/platform/app/Filament/Widgets/Inventory/InventoryKpiHeader.php index 850956ed..eed1f300 100644 --- a/apps/platform/app/Filament/Widgets/Inventory/InventoryKpiHeader.php +++ b/apps/platform/app/Filament/Widgets/Inventory/InventoryKpiHeader.php @@ -14,7 +14,9 @@ use App\Support\Inventory\InventoryKpiBadges; use App\Support\Inventory\TenantCoverageTruth; use App\Support\Inventory\TenantCoverageTruthResolver; +use App\Support\OperationCatalog; use App\Support\OperationRunLinks; +use App\Support\OperationRunType; use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget\Stat; use Illuminate\Support\Facades\Blade; @@ -56,7 +58,7 @@ protected function getStats(): array $inventoryOps = (int) OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) - ->where('type', 'inventory_sync') + ->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value)) ->active() ->count(); diff --git a/apps/platform/app/Jobs/ApplyBackupScheduleRetentionJob.php b/apps/platform/app/Jobs/ApplyBackupScheduleRetentionJob.php index 39e9bc31..106ca162 100644 --- a/apps/platform/app/Jobs/ApplyBackupScheduleRetentionJob.php +++ b/apps/platform/app/Jobs/ApplyBackupScheduleRetentionJob.php @@ -8,8 +8,10 @@ use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Services\Settings\SettingsResolver; +use App\Support\OperationCatalog; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\OperationRunType; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Collection; @@ -36,10 +38,10 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol 'tenant_id' => (int) $schedule->tenant_id, 'user_id' => null, 'initiator_name' => 'System', - 'type' => 'backup_schedule_retention', + 'type' => OperationRunType::BackupScheduleRetention->value, 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, - 'run_identity_hash' => hash('sha256', (string) $schedule->tenant_id.':backup_schedule_retention:'.$schedule->id.':'.Str::uuid()->toString()), + 'run_identity_hash' => hash('sha256', (string) $schedule->tenant_id.':'.OperationRunType::BackupScheduleRetention->value.':'.$schedule->id.':'.Str::uuid()->toString()), 'context' => [ 'backup_schedule_id' => (int) $schedule->id, ], @@ -88,7 +90,7 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol /** @var Collection $keepBackupSetIds */ $keepBackupSetIds = OperationRun::query() ->where('tenant_id', (int) $schedule->tenant_id) - ->where('type', 'backup_schedule_run') + ->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value)) ->where('status', OperationRunStatus::Completed->value) ->where('context->backup_schedule_id', (int) $schedule->id) ->whereNotNull('context->backup_set_id') @@ -103,7 +105,7 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol /** @var Collection $allBackupSetIds */ $allBackupSetIds = OperationRun::query() ->where('tenant_id', (int) $schedule->tenant_id) - ->where('type', 'backup_schedule_run') + ->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value)) ->where('status', OperationRunStatus::Completed->value) ->where('context->backup_schedule_id', (int) $schedule->id) ->whereNotNull('context->backup_set_id') diff --git a/apps/platform/app/Models/BackupSchedule.php b/apps/platform/app/Models/BackupSchedule.php index 10cdd47e..ddd8a708 100644 --- a/apps/platform/app/Models/BackupSchedule.php +++ b/apps/platform/app/Models/BackupSchedule.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Support\OperationCatalog; +use App\Support\OperationRunType; use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -34,11 +36,11 @@ public function tenant(): BelongsTo public function operationRuns(): HasMany { return $this->hasMany(OperationRun::class, 'tenant_id', 'tenant_id') - ->whereIn('type', [ - 'backup_schedule_run', - 'backup_schedule_retention', - 'backup_schedule_purge', - ]) + ->whereIn('type', array_values(array_unique(array_merge( + OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value), + OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleRetention->value), + OperationCatalog::rawValuesForCanonical(OperationRunType::BackupSchedulePurge->value), + )))) ->where('context->backup_schedule_id', (int) $this->getKey()); } } diff --git a/apps/platform/app/Models/OperationRun.php b/apps/platform/app/Models/OperationRun.php index 67b1d2cf..980ff17e 100644 --- a/apps/platform/app/Models/OperationRun.php +++ b/apps/platform/app/Models/OperationRun.php @@ -98,7 +98,7 @@ public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|i : (int) $profile; return $query - ->where('type', OperationRunType::BaselineCompare->value) + ->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCompare->value)) ->where('context->baseline_profile_id', $profileId); } @@ -112,7 +112,7 @@ public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $poli foreach ($policy->coveredTypeNames() as $type) { $query->orWhere(function (Builder $typeQuery) use ($policy, $type): void { $typeQuery - ->where('type', $type) + ->whereIn('type', OperationCatalog::rawValuesForCanonical($type)) ->where(function (Builder $stateQuery) use ($policy, $type): void { $stateQuery ->where(function (Builder $queuedQuery) use ($policy, $type): void { @@ -152,12 +152,18 @@ public function scopeHealthyActive(Builder $query, ?OperationLifecyclePolicy $po return $query ->active() ->where(function (Builder $query) use ($coveredTypes, $policy): void { - $query->whereNotIn('type', $coveredTypes); + $coveredRawTypes = collect($coveredTypes) + ->flatMap(static fn (string $type): array => OperationCatalog::rawValuesForCanonical($type)) + ->unique() + ->values() + ->all(); + + $query->whereNotIn('type', $coveredRawTypes); foreach ($coveredTypes as $type) { $query->orWhere(function (Builder $typeQuery) use ($policy, $type): void { $typeQuery - ->where('type', $type) + ->whereIn('type', OperationCatalog::rawValuesForCanonical($type)) ->where(function (Builder $stateQuery) use ($policy, $type): void { $stateQuery ->where(function (Builder $queuedQuery) use ($policy, $type): void { @@ -343,7 +349,7 @@ public static function latestCompletedCoverageBearingInventorySyncForTenant(int return static::query() ->where('tenant_id', $tenantId) - ->where('type', 'inventory_sync') + ->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value)) ->where('status', OperationRunStatus::Completed->value) ->whereNotNull('completed_at') ->latest('completed_at') @@ -478,11 +484,11 @@ public function baselineGapEnvelope(): array { $context = is_array($this->context) ? $this->context : []; - return match ((string) $this->type) { - 'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps')) + return match ($this->canonicalOperationType()) { + 'baseline.compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps')) ? data_get($context, 'baseline_compare.evidence_gaps') : [], - 'baseline_capture' => is_array(data_get($context, 'baseline_capture.gaps')) + 'baseline.capture' => is_array(data_get($context, 'baseline_capture.gaps')) ? data_get($context, 'baseline_capture.gaps') : [], default => [], diff --git a/apps/platform/app/Services/Directory/EntraGroupSyncService.php b/apps/platform/app/Services/Directory/EntraGroupSyncService.php index 070f908e..1033344b 100644 --- a/apps/platform/app/Services/Directory/EntraGroupSyncService.php +++ b/apps/platform/app/Services/Directory/EntraGroupSyncService.php @@ -14,6 +14,7 @@ use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartResult; use App\Support\Auth\Capabilities; +use App\Support\OperationRunType; use Carbon\CarbonImmutable; class EntraGroupSyncService @@ -32,7 +33,7 @@ public function startManualSync(Tenant $tenant, User $user): ProviderOperationSt return $this->providerStarts->start( tenant: $tenant, connection: null, - operationType: 'entra_group_sync', + operationType: OperationRunType::DirectoryGroupsSync->value, dispatcher: function (OperationRun $run) use ($tenant, $selectionKey): void { $providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null) ? (int) $run->context['provider_connection_id'] diff --git a/apps/platform/app/Services/Directory/RoleDefinitionsSyncService.php b/apps/platform/app/Services/Directory/RoleDefinitionsSyncService.php index 387dadfa..2499d461 100644 --- a/apps/platform/app/Services/Directory/RoleDefinitionsSyncService.php +++ b/apps/platform/app/Services/Directory/RoleDefinitionsSyncService.php @@ -14,6 +14,7 @@ use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartResult; use App\Support\Auth\Capabilities; +use App\Support\OperationRunType; use Carbon\CarbonImmutable; class RoleDefinitionsSyncService @@ -32,7 +33,7 @@ public function startManualSync(Tenant $tenant, User $user): ProviderOperationSt return $this->providerStarts->start( tenant: $tenant, connection: null, - operationType: 'directory_role_definitions.sync', + operationType: OperationRunType::DirectoryRoleDefinitionsSync->value, dispatcher: function (OperationRun $run) use ($tenant): void { $providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null) ? (int) $run->context['provider_connection_id'] diff --git a/apps/platform/app/Services/Evidence/Sources/OperationsSummarySource.php b/apps/platform/app/Services/Evidence/Sources/OperationsSummarySource.php index fe424763..b9a30274 100644 --- a/apps/platform/app/Services/Evidence/Sources/OperationsSummarySource.php +++ b/apps/platform/app/Services/Evidence/Sources/OperationsSummarySource.php @@ -44,6 +44,7 @@ public function collect(Tenant $tenant): array 'entries' => $runs->map(static fn (OperationRun $run): array => [ 'id' => (int) $run->getKey(), 'type' => (string) $run->type, + 'operation_type' => $run->canonicalOperationType(), 'status' => (string) $run->status, 'outcome' => (string) $run->outcome, 'initiator_name' => $run->user?->name, diff --git a/apps/platform/app/Services/Inventory/InventoryMissingService.php b/apps/platform/app/Services/Inventory/InventoryMissingService.php index 76a3ffe4..be223509 100644 --- a/apps/platform/app/Services/Inventory/InventoryMissingService.php +++ b/apps/platform/app/Services/Inventory/InventoryMissingService.php @@ -6,6 +6,8 @@ use App\Models\OperationRun; use App\Models\Tenant; use App\Services\BackupScheduling\PolicyTypeResolver; +use App\Support\OperationCatalog; +use App\Support\OperationRunType; use Illuminate\Database\Eloquent\Collection; class InventoryMissingService @@ -27,7 +29,7 @@ public function missingForSelection(Tenant $tenant, array $selectionPayload): ar $latestRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'inventory_sync') + ->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value)) ->where('status', 'completed') ->where('context->selection_hash', $selectionHash) ->orderByDesc('completed_at') diff --git a/apps/platform/app/Services/Inventory/InventorySyncService.php b/apps/platform/app/Services/Inventory/InventorySyncService.php index e340617d..2c034038 100644 --- a/apps/platform/app/Services/Inventory/InventorySyncService.php +++ b/apps/platform/app/Services/Inventory/InventorySyncService.php @@ -59,7 +59,7 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun 'type' => OperationRunType::InventorySync->value, 'status' => OperationRunStatus::Running->value, 'outcome' => OperationRunOutcome::Pending->value, - 'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':inventory_sync:'.$selectionHash.':'.Str::uuid()->toString()), + 'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':'.OperationRunType::InventorySync->value.':'.$selectionHash.':'.Str::uuid()->toString()), 'context' => array_merge($normalizedSelection, [ 'selection_hash' => $selectionHash, ]), @@ -698,7 +698,7 @@ private function resolveFoundationPolicyAnchor( private function selectionLockKey(Tenant $tenant, string $selectionHash): string { - return sprintf('inventory_sync:tenant:%s:selection:%s', (string) $tenant->getKey(), $selectionHash); + return sprintf('%s:tenant:%s:selection:%s', OperationRunType::InventorySync->value, (string) $tenant->getKey(), $selectionHash); } private function mapGraphFailureToErrorCode(GraphResponse $response): string diff --git a/apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php b/apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php index 3f78cd84..ea68fde6 100644 --- a/apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php +++ b/apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php @@ -12,6 +12,7 @@ use App\Services\Tenants\TenantOperabilityService; use App\Support\Onboarding\OnboardingCheckpoint; use App\Support\Onboarding\OnboardingLifecycleState; +use App\Support\OperationCatalog; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\Verification\VerificationReportOverall; @@ -682,7 +683,7 @@ private function bootstrapOperationTypes(TenantOnboardingSession $draft): array } return array_values(array_filter( - array_map(static fn (mixed $value): string => is_string($value) ? trim($value) : '', $types), + array_map(static fn (mixed $value): string => is_string($value) ? OperationCatalog::canonicalCode($value) : '', $types), static fn (string $value): bool => $value !== '', )); } @@ -709,7 +710,7 @@ private function bootstrapRunMap(TenantOnboardingSession $draft, array $selected continue; } - $runMap[trim($type)] = $normalizedRunId; + $runMap[OperationCatalog::canonicalCode($type)] = $normalizedRunId; } } diff --git a/apps/platform/app/Services/OperationRunService.php b/apps/platform/app/Services/OperationRunService.php index eb163d9a..ef48a6af 100644 --- a/apps/platform/app/Services/OperationRunService.php +++ b/apps/platform/app/Services/OperationRunService.php @@ -1271,7 +1271,7 @@ private function writeTerminalAudit(OperationRun $run): void action: $action, context: [ 'metadata' => [ - 'operation_type' => $run->type, + 'operation_type' => $run->canonicalOperationType(), 'summary_counts' => $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []), 'failure_summary' => $run->failure_summary, 'target_scope' => $executionLegitimacy['target_scope'] ?? ($context['target_scope'] ?? null), diff --git a/apps/platform/app/Services/Operations/QueuedExecutionLegitimacyGate.php b/apps/platform/app/Services/Operations/QueuedExecutionLegitimacyGate.php index 7227b262..b4d67016 100644 --- a/apps/platform/app/Services/Operations/QueuedExecutionLegitimacyGate.php +++ b/apps/platform/app/Services/Operations/QueuedExecutionLegitimacyGate.php @@ -13,6 +13,7 @@ use App\Services\Auth\CapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Tenants\TenantOperabilityService; +use App\Support\OperationCatalog; use App\Support\Operations\ExecutionAuthorityMode; use App\Support\Operations\ExecutionDenialReasonCode; use App\Support\Operations\OperationRunCapabilityResolver; @@ -27,9 +28,9 @@ class QueuedExecutionLegitimacyGate * @var list */ private const SYSTEM_AUTHORITY_ALLOWLIST = [ - 'backup_schedule_run', - 'backup_schedule_retention', - 'backup_schedule_purge', + 'backup.schedule.execute', + 'backup.schedule.retention', + 'backup.schedule.purge', ]; public function __construct( @@ -134,6 +135,7 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision public function buildContext(OperationRun $run): QueuedExecutionContext { $context = is_array($run->context) ? $run->context : []; + $operationType = OperationCatalog::canonicalCode((string) $run->type); $authorityMode = ExecutionAuthorityMode::fromNullable($context['execution_authority_mode'] ?? null) ?? ($run->user_id === null ? ExecutionAuthorityMode::SystemAuthority : ExecutionAuthorityMode::ActorBound); $providerConnectionId = $this->resolveProviderConnectionId($context); @@ -141,26 +143,28 @@ public function buildContext(OperationRun $run): QueuedExecutionContext return new QueuedExecutionContext( run: $run, - operationType: (string) $run->type, + operationType: $operationType, workspaceId: $workspaceId, tenant: $run->tenant, initiator: $run->user, authorityMode: $authorityMode, requiredCapability: is_string($context['required_capability'] ?? null) ? $context['required_capability'] - : $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType((string) $run->type), + : $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType($operationType), providerConnectionId: $providerConnectionId, targetScope: [ 'workspace_id' => $workspaceId, 'tenant_id' => $run->tenant_id !== null ? (int) $run->tenant_id : null, 'provider_connection_id' => $providerConnectionId, ], - prerequisiteClasses: $this->prerequisiteClassesFor((string) $run->type, $providerConnectionId), + prerequisiteClasses: $this->prerequisiteClassesFor($operationType, $providerConnectionId), ); } public function isSystemAuthorityAllowed(string $operationType): bool { + $operationType = OperationCatalog::canonicalCode($operationType); + return in_array($operationType, self::SYSTEM_AUTHORITY_ALLOWLIST, true); } diff --git a/apps/platform/app/Services/Providers/ProviderOperationRegistry.php b/apps/platform/app/Services/Providers/ProviderOperationRegistry.php index 8a4be136..fe68c239 100644 --- a/apps/platform/app/Services/Providers/ProviderOperationRegistry.php +++ b/apps/platform/app/Services/Providers/ProviderOperationRegistry.php @@ -23,8 +23,8 @@ public function definitions(): array 'label' => 'Provider connection check', 'required_capability' => Capabilities::PROVIDER_RUN, ], - 'inventory_sync' => [ - 'operation_type' => 'inventory_sync', + 'inventory.sync' => [ + 'operation_type' => 'inventory.sync', 'module' => 'inventory', 'label' => 'Inventory sync', 'required_capability' => Capabilities::PROVIDER_RUN, @@ -41,14 +41,14 @@ public function definitions(): array 'label' => 'Restore execution', 'required_capability' => Capabilities::TENANT_MANAGE, ], - 'entra_group_sync' => [ - 'operation_type' => 'entra_group_sync', + 'directory.groups.sync' => [ + 'operation_type' => 'directory.groups.sync', 'module' => 'directory_groups', 'label' => 'Directory groups sync', 'required_capability' => Capabilities::TENANT_SYNC, ], - 'directory_role_definitions.sync' => [ - 'operation_type' => 'directory_role_definitions.sync', + 'directory.role_definitions.sync' => [ + 'operation_type' => 'directory.role_definitions.sync', 'module' => 'directory_role_definitions', 'label' => 'Role definitions sync', 'required_capability' => Capabilities::TENANT_MANAGE, @@ -77,9 +77,9 @@ public function providerBindings(): array exceptionNotes: 'Current-release provider binding remains Microsoft-only until a real second provider case exists.', ), ], - 'inventory_sync' => [ + 'inventory.sync' => [ 'microsoft' => $this->activeMicrosoftBinding( - operationType: 'inventory_sync', + operationType: 'inventory.sync', handlerNotes: 'Uses the current Microsoft Intune inventory sync workflow.', exceptionNotes: 'Inventory collection is currently Microsoft Intune-specific provider behavior.', ), @@ -98,16 +98,16 @@ public function providerBindings(): array exceptionNotes: 'Restore execution remains Microsoft-only and must preserve dry-run and audit safeguards.', ), ], - 'entra_group_sync' => [ + 'directory.groups.sync' => [ 'microsoft' => $this->activeMicrosoftBinding( - operationType: 'entra_group_sync', + operationType: 'directory.groups.sync', handlerNotes: 'Uses the current Microsoft Entra group synchronization workflow.', exceptionNotes: 'The operation type keeps current Entra vocabulary until the identity-neutrality follow-up.', ), ], - 'directory_role_definitions.sync' => [ + 'directory.role_definitions.sync' => [ 'microsoft' => $this->activeMicrosoftBinding( - operationType: 'directory_role_definitions.sync', + operationType: 'directory.role_definitions.sync', handlerNotes: 'Uses the current Microsoft directory role definition synchronization workflow.', exceptionNotes: 'Directory role definitions are Microsoft-owned provider semantics.', ), diff --git a/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php b/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php index 98742372..37e8f735 100644 --- a/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php +++ b/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php @@ -597,7 +597,7 @@ private function dispatchFailureAlertSafely(OperationRun $run): void 'body' => 'A findings lifecycle backfill run failed.', 'metadata' => [ 'operation_run_id' => (int) $run->getKey(), - 'operation_type' => (string) $run->type, + 'operation_type' => $run->canonicalOperationType(), 'scope' => (string) data_get($run->context, 'runbook.scope', ''), 'view_run_url' => SystemOperationRunLinks::view($run), ], diff --git a/apps/platform/app/Services/SystemConsole/OperationRunTriageService.php b/apps/platform/app/Services/SystemConsole/OperationRunTriageService.php index 5e6fc233..caf32763 100644 --- a/apps/platform/app/Services/SystemConsole/OperationRunTriageService.php +++ b/apps/platform/app/Services/SystemConsole/OperationRunTriageService.php @@ -14,10 +14,9 @@ final class OperationRunTriageService { private const RETRYABLE_TYPES = [ - 'inventory_sync', + 'inventory.sync', 'policy.sync', - 'policy.sync_one', - 'entra_group_sync', + 'directory.groups.sync', 'findings.lifecycle.backfill', 'rbac.health_check', 'entra.admin_roles.scan', @@ -26,10 +25,9 @@ final class OperationRunTriageService ]; private const CANCELABLE_TYPES = [ - 'inventory_sync', + 'inventory.sync', 'policy.sync', - 'policy.sync_one', - 'entra_group_sync', + 'directory.groups.sync', 'findings.lifecycle.backfill', 'rbac.health_check', 'entra.admin_roles.scan', @@ -46,7 +44,7 @@ public function canRetry(OperationRun $run): bool { return (string) $run->status === OperationRunStatus::Completed->value && (string) $run->outcome === OperationRunOutcome::Failed->value - && in_array((string) $run->type, self::RETRYABLE_TYPES, true); + && in_array($run->canonicalOperationType(), self::RETRYABLE_TYPES, true); } public function canCancel(OperationRun $run): bool @@ -55,7 +53,7 @@ public function canCancel(OperationRun $run): bool OperationRunStatus::Queued->value, OperationRunStatus::Running->value, ], true) - && in_array((string) $run->type, self::CANCELABLE_TYPES, true); + && in_array($run->canonicalOperationType(), self::CANCELABLE_TYPES, true); } public function retry(OperationRun $run, PlatformUser $actor): OperationRun @@ -83,7 +81,7 @@ public function retry(OperationRun $run, PlatformUser $actor): OperationRun 'tenant_id' => $run->tenant_id !== null ? (int) $run->tenant_id : null, 'user_id' => null, 'initiator_name' => $actor->name ?? 'Platform operator', - 'type' => (string) $run->type, + 'type' => $run->canonicalOperationType(), 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, 'run_identity_hash' => hash('sha256', 'retry|'.$run->getKey().'|'.now()->format('U.u').'|'.bin2hex(random_bytes(8))), @@ -100,7 +98,7 @@ public function retry(OperationRun $run, PlatformUser $actor): OperationRun metadata: [ 'source_run_id' => (int) $run->getKey(), 'new_run_id' => (int) $retryRun->getKey(), - 'operation_type' => (string) $run->type, + 'operation_type' => $run->canonicalOperationType(), ], run: $retryRun, ); @@ -152,7 +150,7 @@ public function cancel(OperationRun $run, PlatformUser $actor, string $reason): actor: $actor, action: 'platform.system_console.cancel', metadata: [ - 'operation_type' => (string) $run->type, + 'operation_type' => $run->canonicalOperationType(), 'reason' => $reason, ], run: $cancelledRun, @@ -192,7 +190,7 @@ public function markInvestigated(OperationRun $run, PlatformUser $actor, string action: 'platform.system_console.mark_investigated', metadata: [ 'reason' => $reason, - 'operation_type' => (string) $run->type, + 'operation_type' => $run->canonicalOperationType(), ], run: $run, ); diff --git a/apps/platform/app/Support/Baselines/BaselineCompareStats.php b/apps/platform/app/Support/Baselines/BaselineCompareStats.php index 41163ecb..880333e4 100644 --- a/apps/platform/app/Support/Baselines/BaselineCompareStats.php +++ b/apps/platform/app/Support/Baselines/BaselineCompareStats.php @@ -12,6 +12,7 @@ use App\Models\OperationRun; use App\Models\Tenant; use App\Services\Baselines\BaselineSnapshotTruthResolver; +use App\Support\OperationCatalog; use App\Support\OperationRunStatus; use App\Support\OperationRunType; use App\Support\ReasonTranslation\ReasonPresenter; @@ -166,7 +167,7 @@ public static function forTenant(?Tenant $tenant): self $latestRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'baseline_compare') + ->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCompare->value)) ->latest('id') ->first(); @@ -457,7 +458,7 @@ public static function forWidget(?Tenant $tenant): self $latestRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'baseline_compare') + ->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCompare->value)) ->where('context->baseline_profile_id', (string) $profile->getKey()) ->whereNotNull('completed_at') ->latest('completed_at') diff --git a/apps/platform/app/Support/OperationCatalog.php b/apps/platform/app/Support/OperationCatalog.php index f99e802b..e1d649ca 100644 --- a/apps/platform/app/Support/OperationCatalog.php +++ b/apps/platform/app/Support/OperationCatalog.php @@ -121,17 +121,29 @@ public static function boundaryClassification(?PlatformVocabularyGlossary $gloss } /** + * Read-side compatibility helper for historical rows only. + * + * Writers must emit the canonical code directly instead of translating a + * legacy alias through this method. + * * @return list */ public static function rawValuesForCanonical(string $canonicalCode): array { - return array_values(array_map( + $canonicalCode = trim($canonicalCode); + $values = array_values(array_map( static fn (OperationTypeAlias $alias): string => $alias->rawValue, array_filter( self::operationAliases(), - static fn (OperationTypeAlias $alias): bool => $alias->canonicalCode === trim($canonicalCode), + static fn (OperationTypeAlias $alias): bool => $alias->canonicalCode === $canonicalCode, ), )); + + if (array_key_exists($canonicalCode, self::canonicalDefinitions()) && ! in_array($canonicalCode, $values, true)) { + $values[] = $canonicalCode; + } + + return $values; } /** @@ -191,6 +203,21 @@ public static function resolve(string $operationType): OperationTypeResolution ); } + $definition = self::canonicalDefinitions()[$operationType] ?? null; + + if ($definition instanceof CanonicalOperationType) { + return new OperationTypeResolution( + rawValue: $operationType, + canonical: $definition, + aliasesConsidered: array_values(array_filter( + $aliases, + static fn (OperationTypeAlias $alias): bool => $alias->canonicalCode === $operationType, + )), + aliasStatus: 'canonical', + wasLegacyAlias: false, + ); + } + return new OperationTypeResolution( rawValue: $operationType, canonical: new CanonicalOperationType( @@ -262,29 +289,29 @@ private static function operationAliases(): array { return [ new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true), - new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', true, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'), + new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', false, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'), new OperationTypeAlias('policy.capture_snapshot', 'policy.snapshot', 'canonical', true), new OperationTypeAlias('policy.delete', 'policy.delete', 'canonical', true), - new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', true, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'), + new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', false, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'), new OperationTypeAlias('policy.export', 'policy.export', 'canonical', true), new OperationTypeAlias('provider.connection.check', 'provider.connection.check', 'canonical', true), - new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', true, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'), + new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', false, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'), new OperationTypeAlias('provider.inventory.sync', 'inventory.sync', 'legacy_alias', false, 'Provider-prefixed historical inventory sync values share the same operator meaning as inventory sync.', 'Avoid emitting provider.inventory.sync on new platform-owned surfaces.'), new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true), new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'), - new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', true, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'), + new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', false, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'), new OperationTypeAlias('backup_set.update', 'backup_set.update', 'canonical', true), new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true), new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true), - new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', true, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'), - new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', true, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'), - new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', true, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'), - new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', true, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'), + new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', false, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'), + new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', false, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'), + new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', false, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'), + new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'), new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true), new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true), new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true), new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true), - new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', true, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'), + new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', false, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'), new OperationTypeAlias('restore_run.delete', 'restore_run.delete', 'canonical', true), new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true), new OperationTypeAlias('restore_run.force_delete', 'restore_run.force_delete', 'canonical', true), @@ -296,9 +323,9 @@ private static function operationAliases(): array new OperationTypeAlias('alerts.deliver', 'alerts.deliver', 'canonical', true), new OperationTypeAlias('baseline.capture', 'baseline.capture', 'canonical', true), new OperationTypeAlias('baseline.compare', 'baseline.compare', 'canonical', true), - new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', true, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'), - new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', true, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'), - new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', true, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'), + new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', false, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'), + new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', false, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'), + new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', false, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'), new OperationTypeAlias('entra.admin_roles.scan', 'entra.admin_roles.scan', 'canonical', true), new OperationTypeAlias('tenant.review_pack.generate', 'tenant.review_pack.generate', 'canonical', true), new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true), diff --git a/apps/platform/app/Support/OperationRunLinks.php b/apps/platform/app/Support/OperationRunLinks.php index e4a9faa8..3f259427 100644 --- a/apps/platform/app/Support/OperationRunLinks.php +++ b/apps/platform/app/Support/OperationRunLinks.php @@ -108,7 +108,7 @@ public static function index( } if (is_string($operationType) && $operationType !== '') { - $parameters['tableFilters']['type']['value'] = $operationType; + $parameters['tableFilters']['type']['value'] = OperationCatalog::canonicalCode($operationType); } return route('admin.operations.index', $parameters); @@ -145,17 +145,18 @@ public static function related(OperationRun $run, ?Tenant $tenant): array } $providerConnectionId = $context['provider_connection_id'] ?? null; + $canonicalType = $run->canonicalOperationType(); if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) { $links['Provider Connections'] = ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin'); $links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin'); } - if ($run->type === 'inventory_sync') { + if ($canonicalType === 'inventory.sync') { $links['Inventory'] = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant); } - if (in_array($run->type, ['policy.sync', 'policy.sync_one'], true)) { + if ($canonicalType === 'policy.sync') { $links['Policies'] = PolicyResource::getUrl('index', panel: 'tenant', tenant: $tenant); $policyId = $context['policy_id'] ?? null; @@ -164,15 +165,15 @@ public static function related(OperationRun $run, ?Tenant $tenant): array } } - if ($run->type === 'entra_group_sync') { + if ($canonicalType === 'directory.groups.sync') { $links['Directory Groups'] = EntraGroupResource::scopedUrl('index', tenant: $tenant); } - if ($run->type === 'baseline_compare') { + if ($canonicalType === 'baseline.compare') { $links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant); } - if ($run->type === 'baseline_capture') { + if ($canonicalType === 'baseline.capture') { $snapshotId = data_get($context, 'result.snapshot_id'); if (is_numeric($snapshotId)) { @@ -180,7 +181,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array } } - if ($run->type === 'backup_set.update') { + if ($canonicalType === 'backup_set.update') { $links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant); $backupSetId = $context['backup_set_id'] ?? null; @@ -189,11 +190,11 @@ public static function related(OperationRun $run, ?Tenant $tenant): array } } - if (in_array($run->type, ['backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge'], true)) { + if (in_array($canonicalType, ['backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge'], true)) { $links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant); } - if ($run->type === 'restore.execute') { + if ($canonicalType === 'restore.execute') { $links['Restore Runs'] = RestoreRunResource::getUrl('index', panel: 'tenant', tenant: $tenant); $restoreRunId = $context['restore_run_id'] ?? null; @@ -202,7 +203,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array } } - if ($run->type === 'tenant.evidence.snapshot.generate') { + if ($canonicalType === 'tenant.evidence.snapshot.generate') { $snapshot = EvidenceSnapshot::query() ->where('operation_run_id', (int) $run->getKey()) ->latest('id') @@ -213,7 +214,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array } } - if ($run->type === 'tenant.review.compose') { + if ($canonicalType === 'tenant.review.compose') { $review = TenantReview::query() ->where('operation_run_id', (int) $run->getKey()) ->latest('id') @@ -224,7 +225,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array } } - if ($run->type === 'tenant.review_pack.generate') { + if ($canonicalType === 'tenant.review_pack.generate') { $pack = ReviewPack::query() ->where('operation_run_id', (int) $run->getKey()) ->latest('id') diff --git a/apps/platform/app/Support/OperationRunType.php b/apps/platform/app/Support/OperationRunType.php index 22c7c935..97c35248 100644 --- a/apps/platform/app/Support/OperationRunType.php +++ b/apps/platform/app/Support/OperationRunType.php @@ -4,17 +4,16 @@ enum OperationRunType: string { - case BaselineCapture = 'baseline_capture'; - case BaselineCompare = 'baseline_compare'; - case InventorySync = 'inventory_sync'; + case BaselineCapture = 'baseline.capture'; + case BaselineCompare = 'baseline.compare'; + case InventorySync = 'inventory.sync'; case PolicySync = 'policy.sync'; - case PolicySyncOne = 'policy.sync_one'; - case DirectoryGroupsSync = 'entra_group_sync'; + case DirectoryGroupsSync = 'directory.groups.sync'; case BackupSetUpdate = 'backup_set.update'; - case BackupScheduleExecute = 'backup_schedule_run'; - case BackupScheduleRetention = 'backup_schedule_retention'; - case BackupSchedulePurge = 'backup_schedule_purge'; - case DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync'; + case BackupScheduleExecute = 'backup.schedule.execute'; + case BackupScheduleRetention = 'backup.schedule.retention'; + case BackupSchedulePurge = 'backup.schedule.purge'; + case DirectoryRoleDefinitionsSync = 'directory.role_definitions.sync'; case RestoreExecute = 'restore.execute'; case EntraAdminRolesScan = 'entra.admin_roles.scan'; case ReviewPackGenerate = 'tenant.review_pack.generate'; @@ -29,24 +28,6 @@ public static function values(): array public function canonicalCode(): string { - return match ($this) { - self::BaselineCapture => 'baseline.capture', - self::BaselineCompare => 'baseline.compare', - self::InventorySync => 'inventory.sync', - self::PolicySync, self::PolicySyncOne => 'policy.sync', - self::DirectoryGroupsSync => 'directory.groups.sync', - self::BackupSetUpdate => 'backup_set.update', - self::BackupScheduleExecute => 'backup.schedule.execute', - self::BackupScheduleRetention => 'backup.schedule.retention', - self::BackupSchedulePurge => 'backup.schedule.purge', - self::DirectoryRoleDefinitionsSync => 'directory.role_definitions.sync', - self::RestoreExecute => 'restore.execute', - self::EntraAdminRolesScan => 'entra.admin_roles.scan', - self::ReviewPackGenerate => 'tenant.review_pack.generate', - self::TenantReviewCompose => 'tenant.review.compose', - self::EvidenceSnapshotGenerate => 'tenant.evidence.snapshot.generate', - self::RbacHealthCheck => 'rbac.health_check', - default => $this->value, - }; + return $this->value; } } diff --git a/apps/platform/app/Support/Operations/OperationLifecyclePolicy.php b/apps/platform/app/Support/Operations/OperationLifecyclePolicy.php index f523f31d..58387a20 100644 --- a/apps/platform/app/Support/Operations/OperationLifecyclePolicy.php +++ b/apps/platform/app/Support/Operations/OperationLifecyclePolicy.php @@ -4,6 +4,7 @@ namespace App\Support\Operations; +use App\Support\OperationCatalog; use Illuminate\Support\Arr; final class OperationLifecyclePolicy @@ -43,7 +44,8 @@ public function definition(string $operationType): ?array return null; } - $definition = $this->coveredTypes()[$operationType] ?? null; + $canonicalType = OperationCatalog::canonicalCode($operationType); + $definition = $this->coveredTypes()[$canonicalType] ?? null; if (! is_array($definition)) { return null; diff --git a/apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php b/apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php index d00bd5a6..da627a8a 100644 --- a/apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php +++ b/apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php @@ -4,6 +4,7 @@ use App\Models\OperationRun; use App\Support\Auth\Capabilities; +use App\Support\OperationCatalog; final class OperationRunCapabilityResolver { @@ -14,18 +15,18 @@ public function requiredCapabilityForRun(OperationRun $run): ?string public function requiredCapabilityForType(string $operationType): ?string { - $operationType = trim($operationType); + $operationType = OperationCatalog::canonicalCode($operationType); if ($operationType === '') { return null; } return match ($operationType) { - 'inventory_sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN, - 'entra_group_sync' => Capabilities::TENANT_SYNC, - 'backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN, + 'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN, + 'directory.groups.sync' => Capabilities::TENANT_SYNC, + 'backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN, 'restore.execute' => Capabilities::TENANT_MANAGE, - 'directory_role_definitions.sync' => Capabilities::TENANT_MANAGE, + 'directory.role_definitions.sync' => Capabilities::TENANT_MANAGE, 'alerts.evaluate', 'alerts.deliver' => Capabilities::ALERTS_VIEW, 'tenant.review.compose' => Capabilities::TENANT_REVIEW_VIEW, @@ -40,15 +41,15 @@ public function requiredCapabilityForType(string $operationType): ?string public function requiredExecutionCapabilityForType(string $operationType): ?string { - $operationType = trim($operationType); + $operationType = OperationCatalog::canonicalCode($operationType); if ($operationType === '') { return null; } return match ($operationType) { - 'provider.connection.check', 'provider.inventory.sync', 'provider.compliance.snapshot' => Capabilities::PROVIDER_RUN, - 'policy.sync', 'policy.sync_one', 'tenant.sync' => Capabilities::TENANT_SYNC, + 'provider.connection.check', 'inventory.sync', 'compliance.snapshot' => Capabilities::PROVIDER_RUN, + 'policy.sync', 'tenant.sync' => Capabilities::TENANT_SYNC, 'policy.delete' => Capabilities::TENANT_MANAGE, 'assignments.restore', 'restore.execute' => Capabilities::TENANT_MANAGE, 'tenant.review.compose' => Capabilities::TENANT_REVIEW_MANAGE, diff --git a/apps/platform/app/Support/OpsUx/OperationUxPresenter.php b/apps/platform/app/Support/OpsUx/OperationUxPresenter.php index 48849c2b..758770c0 100644 --- a/apps/platform/app/Support/OpsUx/OperationUxPresenter.php +++ b/apps/platform/app/Support/OpsUx/OperationUxPresenter.php @@ -567,7 +567,7 @@ private static function terminalSupportingLines(OperationRun $run): array private static function baselineTruthChangeLine(OperationRun $run): ?string { - if ((string) $run->type !== 'baseline_capture') { + if ($run->canonicalOperationType() !== 'baseline.capture') { return null; } diff --git a/apps/platform/config/tenantpilot.php b/apps/platform/config/tenantpilot.php index aaefb635..ce4edfa3 100644 --- a/apps/platform/config/tenantpilot.php +++ b/apps/platform/config/tenantpilot.php @@ -20,7 +20,7 @@ 'schedule_minutes' => (int) env('TENANTPILOT_OPERATION_RUN_RECONCILIATION_SCHEDULE_MINUTES', 5), ], 'covered_types' => [ - 'baseline_capture' => [ + 'baseline.capture' => [ 'job_class' => \App\Jobs\CaptureBaselineSnapshotJob::class, 'queued_stale_after_seconds' => 600, 'running_stale_after_seconds' => 1800, @@ -28,7 +28,7 @@ 'direct_failed_bridge' => true, 'scheduled_reconciliation' => true, ], - 'baseline_compare' => [ + 'baseline.compare' => [ 'job_class' => \App\Jobs\CompareBaselineToTenantJob::class, 'queued_stale_after_seconds' => 600, 'running_stale_after_seconds' => 1800, @@ -36,7 +36,7 @@ 'direct_failed_bridge' => true, 'scheduled_reconciliation' => true, ], - 'inventory_sync' => [ + 'inventory.sync' => [ 'job_class' => \App\Jobs\RunInventorySyncJob::class, 'queued_stale_after_seconds' => 300, 'running_stale_after_seconds' => 1200, @@ -52,15 +52,7 @@ 'direct_failed_bridge' => true, 'scheduled_reconciliation' => true, ], - 'policy.sync_one' => [ - 'job_class' => \App\Jobs\SyncPoliciesJob::class, - 'queued_stale_after_seconds' => 300, - 'running_stale_after_seconds' => 900, - 'expected_max_runtime_seconds' => 180, - 'direct_failed_bridge' => true, - 'scheduled_reconciliation' => true, - ], - 'entra_group_sync' => [ + 'directory.groups.sync' => [ 'job_class' => \App\Jobs\EntraGroupSyncJob::class, 'queued_stale_after_seconds' => 300, 'running_stale_after_seconds' => 900, @@ -68,7 +60,7 @@ 'direct_failed_bridge' => false, 'scheduled_reconciliation' => true, ], - 'directory_role_definitions.sync' => [ + 'directory.role_definitions.sync' => [ 'job_class' => \App\Jobs\SyncRoleDefinitionsJob::class, 'queued_stale_after_seconds' => 300, 'running_stale_after_seconds' => 900, @@ -84,7 +76,7 @@ 'direct_failed_bridge' => false, 'scheduled_reconciliation' => true, ], - 'backup_schedule_run' => [ + 'backup.schedule.execute' => [ 'job_class' => \App\Jobs\RunBackupScheduleJob::class, 'queued_stale_after_seconds' => 300, 'running_stale_after_seconds' => 1200, diff --git a/apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php b/apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php index b5f45066..28f326e5 100644 --- a/apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php +++ b/apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php @@ -4,6 +4,8 @@ use App\Support\Governance\PlatformVocabularyGlossary; use App\Support\OperationCatalog; +use App\Support\OperationRunType; +use App\Services\Providers\ProviderOperationRegistry; it('keeps touched registry ownership metadata inside the allowed three-way boundary classification', function (): void { $classifications = collect(app(PlatformVocabularyGlossary::class)->registries()) @@ -25,4 +27,31 @@ expect($glossary->term('policy_type')?->boundaryClassification)->toBe(PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC) ->and($glossary->resolveAlias('policy_type', 'compare')?->termKey)->toBe('governed_subject') ->and(OperationCatalog::canonicalCode('baseline_capture'))->toBe('baseline.capture'); -}); \ No newline at end of file +}); + +it('guards canonical operation type writers against raw alias reintroduction', function (): void { + $legacyAliases = [ + 'baseline_capture', + 'baseline_compare', + 'inventory_sync', + 'entra_group_sync', + 'backup_schedule_run', + 'backup_schedule_retention', + 'backup_schedule_purge', + 'directory_role_definitions.sync', + ]; + + $registry = app(ProviderOperationRegistry::class); + $registryTypes = array_merge( + array_keys($registry->definitions()), + collect($registry->definitions())->pluck('operation_type')->all(), + array_keys($registry->providerBindings()), + collect($registry->providerBindings()) + ->flatMap(static fn (array $bindings): array => collect($bindings)->pluck('operation_type')->all()) + ->all(), + OperationRunType::values(), + array_keys(config('tenantpilot.operations.lifecycle.covered_types', [])), + ); + + expect(array_values(array_intersect($legacyAliases, $registryTypes)))->toBe([]); +}); diff --git a/apps/platform/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php b/apps/platform/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php index 47cbaab6..88151b24 100644 --- a/apps/platform/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php +++ b/apps/platform/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php @@ -82,7 +82,7 @@ $retentionRun = OperationRun::query() ->where('tenant_id', (int) $tenant->id) - ->where('type', 'backup_schedule_retention') + ->where('type', 'backup.schedule.retention') ->latest('id') ->first(); diff --git a/apps/platform/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php b/apps/platform/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php index e1cf85ee..f16c6c97 100644 --- a/apps/platform/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php +++ b/apps/platform/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php @@ -37,7 +37,7 @@ expect(OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'backup_schedule_run') + ->where('type', 'backup.schedule.execute') ->count())->toBe(1); Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1); @@ -45,7 +45,7 @@ Bus::assertDispatched(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($tenant): bool { return $job->backupScheduleId !== null && $job->operationRun?->tenant_id === $tenant->getKey() - && $job->operationRun?->type === 'backup_schedule_run'; + && $job->operationRun?->type === 'backup.schedule.execute'; }); }); @@ -74,7 +74,7 @@ $operationRunService->ensureRunWithIdentityStrict( tenant: $tenant, - type: 'backup_schedule_run', + type: 'backup.schedule.execute', identityInputs: [ 'backup_schedule_id' => (int) $schedule->id, 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), @@ -94,7 +94,7 @@ expect(OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'backup_schedule_run') + ->where('type', 'backup.schedule.execute') ->count())->toBe(1); $schedule->refresh(); @@ -133,7 +133,7 @@ ->and($result['scanned_schedules'])->toBe(0) ->and(OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'backup_schedule_run') + ->where('type', 'backup.schedule.execute') ->count())->toBe(0); Bus::assertNotDispatched(RunBackupScheduleJob::class); diff --git a/apps/platform/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php b/apps/platform/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php index 927a3271..63f5341a 100644 --- a/apps/platform/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php +++ b/apps/platform/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php @@ -48,7 +48,7 @@ $operationRun = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule_run') + ->where('type', 'backup.schedule.execute') ->first(); expect($operationRun)->not->toBeNull(); @@ -96,7 +96,7 @@ $runs = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule_run') + ->where('type', 'backup.schedule.execute') ->pluck('id') ->all(); @@ -133,7 +133,7 @@ $operationRun = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule_run') + ->where('type', 'backup.schedule.execute') ->first(); expect($operationRun)->not->toBeNull(); @@ -180,7 +180,7 @@ $runs = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule_run') + ->where('type', 'backup.schedule.execute') ->pluck('id') ->all(); @@ -226,7 +226,7 @@ expect(OperationRun::query() ->where('tenant_id', $tenant->id) - ->whereIn('type', ['backup_schedule_run', 'backup_schedule_run']) + ->whereIn('type', ['backup.schedule.execute']) ->count()) ->toBe(0); }); @@ -270,13 +270,13 @@ expect(OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule_run') + ->where('type', 'backup.schedule.execute') ->count()) ->toBe(2); expect(OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule_run') + ->where('type', 'backup.schedule.execute') ->pluck('user_id') ->unique() ->values() @@ -326,13 +326,13 @@ expect(OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule_run') + ->where('type', 'backup.schedule.execute') ->count()) ->toBe(2); expect(OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule_run') + ->where('type', 'backup.schedule.execute') ->pluck('user_id') ->unique() ->values() @@ -382,7 +382,7 @@ $operationRunService = app(OperationRunService::class); $existing = $operationRunService->ensureRunWithIdentity( tenant: $tenant, - type: 'backup_schedule_run', + type: 'backup.schedule.execute', identityInputs: [ 'backup_schedule_id' => (int) $scheduleA->getKey(), 'nonce' => 'existing', @@ -403,7 +403,7 @@ expect(OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_schedule_run') + ->where('type', 'backup.schedule.execute') ->count()) ->toBe(3); diff --git a/apps/platform/tests/Feature/Console/TenantpilotPurgeNonPersistentDataTest.php b/apps/platform/tests/Feature/Console/TenantpilotPurgeNonPersistentDataTest.php index 4338a4fa..3b9d4bbf 100644 --- a/apps/platform/tests/Feature/Console/TenantpilotPurgeNonPersistentDataTest.php +++ b/apps/platform/tests/Feature/Console/TenantpilotPurgeNonPersistentDataTest.php @@ -129,12 +129,12 @@ expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(1); expect(OperationRun::query() ->where('tenant_id', $tenantA->id) - ->where('type', 'backup_schedule_purge') + ->where('type', 'backup.schedule.purge') ->exists())->toBeTrue(); $purgeRun = OperationRun::query() ->where('tenant_id', $tenantA->id) - ->where('type', 'backup_schedule_purge') + ->where('type', 'backup.schedule.purge') ->latest('id') ->first(); diff --git a/apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php b/apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php index 43546e1f..e0bfa5ca 100644 --- a/apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php +++ b/apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php @@ -39,7 +39,7 @@ $run = OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) - ->where('type', 'entra_group_sync') + ->where('type', 'directory.groups.sync') ->latest('id') ->first(); diff --git a/apps/platform/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php b/apps/platform/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php index 1895e8cc..8e6f9198 100644 --- a/apps/platform/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php +++ b/apps/platform/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php @@ -25,7 +25,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'entra_group_sync') + ->where('type', 'directory.groups.sync') ->where('context->slot_key', $slotKey) ->first(); diff --git a/apps/platform/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php b/apps/platform/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php index ea6b724f..867307db 100644 --- a/apps/platform/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php +++ b/apps/platform/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php @@ -31,7 +31,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'entra_group_sync') + ->where('type', 'directory.groups.sync') ->latest('id') ->first(); diff --git a/apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php b/apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php index fa037fce..2b8cf360 100644 --- a/apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php +++ b/apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php @@ -203,6 +203,33 @@ function operationRunFilterIndicatorLabels($component): array ->assertCanNotSeeTableRecords([$otherRun]); }); +it('shows one canonical operation filter option for current and historical inventory values', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + foreach (['inventory.sync', 'inventory_sync', 'provider.inventory.sync'] as $type) { + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => $type, + ]); + } + + Filament::setTenant($tenant, true); + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + + $component = Livewire::actingAs($user) + ->test(Operations::class); + + /** @var SelectFilter|null $filter */ + $filter = $component->instance()->getTable()->getFilter('type'); + + expect($filter)->not->toBeNull(); + expect($filter?->getOptions())->toBe([ + 'inventory.sync' => 'Inventory sync', + ]); +}); + it('clears tenant-sensitive persisted filters when the canonical tenant context changes', function (): void { $tenantA = Tenant::factory()->create(); [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); diff --git a/apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php b/apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php index c14d7c26..cd120840 100644 --- a/apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php +++ b/apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Tests\Support\OpsUx\SourceFileScanner; +use App\Support\OperationRunLinks; /** * @return array @@ -158,3 +159,10 @@ function operationRunLinkContractViolations(array $paths, array $allowlist = []) ->and($violations[0]['expectedHelper'])->toContain('OperationRunLinks::view') ->and($violations[0]['reason'])->toContain('bypasses the canonical admin link helper'); })->group('surface-guard'); + +it('canonicalizes operation type query parameters for operation collection links', function (): void { + $url = OperationRunLinks::index(operationType: 'inventory_sync'); + + expect($url)->toContain('inventory.sync') + ->not->toContain('inventory_sync'); +})->group('surface-guard'); diff --git a/apps/platform/tests/Feature/Guards/ProviderDispatchGateCoverageTest.php b/apps/platform/tests/Feature/Guards/ProviderDispatchGateCoverageTest.php index d0bf7911..f5ccee04 100644 --- a/apps/platform/tests/Feature/Guards/ProviderDispatchGateCoverageTest.php +++ b/apps/platform/tests/Feature/Guards/ProviderDispatchGateCoverageTest.php @@ -57,7 +57,7 @@ function providerDispatchGateSlice(string $source, string $startAnchor, ?string 'end' => 'public function sync(', 'required' => [ 'return $this->providerStarts->start(', - "operationType: 'entra_group_sync'", + 'operationType: OperationRunType::DirectoryGroupsSync->value', 'EntraGroupSyncJob::dispatch(', '->afterCommit()', ], @@ -69,7 +69,7 @@ function providerDispatchGateSlice(string $source, string $startAnchor, ?string 'end' => 'public function sync(', 'required' => [ 'return $this->providerStarts->start(', - "operationType: 'directory_role_definitions.sync'", + 'operationType: OperationRunType::DirectoryRoleDefinitionsSync->value', 'SyncRoleDefinitionsJob::dispatch(', '->afterCommit()', ], diff --git a/apps/platform/tests/Feature/Inventory/InventorySyncButtonTest.php b/apps/platform/tests/Feature/Inventory/InventorySyncButtonTest.php index a4416657..628fd119 100644 --- a/apps/platform/tests/Feature/Inventory/InventorySyncButtonTest.php +++ b/apps/platform/tests/Feature/Inventory/InventorySyncButtonTest.php @@ -35,7 +35,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->id) ->where('user_id', $user->id) - ->where('type', 'inventory_sync') + ->where('type', 'inventory.sync') ->latest('id') ->first(); @@ -70,7 +70,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'inventory_sync') + ->where('type', 'inventory.sync') ->latest('id') ->first(); @@ -101,7 +101,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'inventory_sync') + ->where('type', 'inventory.sync') ->latest('id') ->first(); @@ -132,7 +132,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'inventory_sync') + ->where('type', 'inventory.sync') ->latest('id') ->first(); @@ -189,7 +189,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'inventory_sync') + ->where('type', 'inventory.sync') ->latest('id') ->first(); @@ -216,7 +216,7 @@ Queue::assertNothingPushed(); - expect(OperationRun::query()->where('tenant_id', $tenantB->id)->where('type', 'inventory_sync')->exists())->toBeFalse(); + expect(OperationRun::query()->where('tenant_id', $tenantB->id)->where('type', 'inventory.sync')->exists())->toBeFalse(); expect(OperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse(); }); @@ -234,7 +234,7 @@ $opService = app(OperationRunService::class); $existing = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'inventory_sync', + type: 'inventory.sync', identityInputs: [ 'selection_hash' => $computed['selection_hash'], ], @@ -253,7 +253,7 @@ ->callAction('run_inventory_sync', data: ['policy_types' => $computed['selection']['policy_types']]); Queue::assertNothingPushed(); - expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory_sync')->count())->toBe(1); + expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory.sync')->count())->toBe(1); }); it('disables inventory sync start action for readonly users', function () { @@ -268,5 +268,5 @@ ->assertActionDisabled('run_inventory_sync'); Queue::assertNothingPushed(); - expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory_sync')->exists())->toBeFalse(); + expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory.sync')->exists())->toBeFalse(); }); diff --git a/apps/platform/tests/Feature/Inventory/InventorySyncServiceTest.php b/apps/platform/tests/Feature/Inventory/InventorySyncServiceTest.php index 2a55689f..84b75edc 100644 --- a/apps/platform/tests/Feature/Inventory/InventorySyncServiceTest.php +++ b/apps/platform/tests/Feature/Inventory/InventorySyncServiceTest.php @@ -116,7 +116,7 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array $opRun = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'inventory_sync', + type: 'inventory.sync', identityInputs: [ 'selection_hash' => $computed['selection_hash'], ], @@ -617,7 +617,7 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array ]; $hash = app(\App\Services\Inventory\InventorySelectionHasher::class)->hash($selection); - $lock = Cache::lock("inventory_sync:tenant:{$tenant->id}:selection:{$hash}", 900); + $lock = Cache::lock("inventory.sync:tenant:{$tenant->id}:selection:{$hash}", 900); expect($lock->get())->toBeTrue(); $run = executeInventorySyncNow($tenant, $selection); diff --git a/apps/platform/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php b/apps/platform/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php index e2e790cc..b922549b 100644 --- a/apps/platform/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php +++ b/apps/platform/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php @@ -40,7 +40,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'inventory_sync') + ->where('type', 'inventory.sync') ->latest('id') ->first(); diff --git a/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php b/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php index b7c70e88..326b17c0 100644 --- a/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php +++ b/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard; +use App\Models\AuditLog; use App\Models\OperationRun; use App\Models\ProviderConnection; use App\Models\Tenant; @@ -10,6 +11,7 @@ use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Support\Audit\AuditActionId; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\Providers\ProviderConnectionType; @@ -384,7 +386,7 @@ $bootstrapRun = OperationRun::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), 'tenant_id' => (int) $tenant->getKey(), - 'type' => 'inventory_sync', + 'type' => 'inventory.sync', 'status' => OperationRunStatus::Completed->value, 'outcome' => OperationRunOutcome::Blocked->value, 'context' => [ @@ -520,7 +522,7 @@ $session->refresh(); - expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory_sync', 'compliance.snapshot']); + expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory.sync', 'compliance.snapshot']); }); it('filters unsupported bootstrap selections from persisted onboarding drafts', function (): void { @@ -618,12 +620,12 @@ 'restore.execute', 'entra_group_sync', 'directory_role_definitions.sync', - ]))->toBe(['inventory_sync', 'compliance.snapshot']); + ]))->toBe(['inventory.sync', 'compliance.snapshot']); $optionsMethod = new \ReflectionMethod($component->instance(), 'bootstrapOperationOptions'); $optionsMethod->setAccessible(true); - expect(array_keys($optionsMethod->invoke($component->instance())))->toBe(['inventory_sync', 'compliance.snapshot']); + expect(array_keys($optionsMethod->invoke($component->instance())))->toBe(['inventory.sync', 'compliance.snapshot']); }); it('returns resumable drafts with missing provider connections to the provider connection step', function (): void { @@ -1464,7 +1466,7 @@ expect(OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) - ->where('type', 'inventory_sync') + ->where('type', 'inventory.sync') ->count())->toBe(1); expect(OperationRun::query() @@ -1475,9 +1477,17 @@ $session->refresh(); $runs = $session->state['bootstrap_operation_runs'] ?? []; expect($runs)->toBeArray(); - expect($runs['inventory_sync'] ?? null)->toBeInt(); + expect($runs['inventory.sync'] ?? null)->toBeInt(); expect($runs['compliance.snapshot'] ?? null)->toBeNull(); - expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory_sync', 'compliance.snapshot']); + expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory.sync', 'compliance.snapshot']); + + $audit = AuditLog::query() + ->where('action', AuditActionId::ManagedTenantOnboardingBootstrapStarted->value) + ->latest('id') + ->firstOrFail(); + + expect(data_get($audit->metadata, 'operation_types'))->toBe(['inventory.sync', 'compliance.snapshot']) + ->and(data_get($audit->metadata, 'started_operation_type'))->toBe('inventory.sync'); }); it('starts the next pending bootstrap action after the prior one completes successfully', function (): void { @@ -1533,11 +1543,11 @@ ]), ]); - $component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']); + $component->call('startBootstrap', ['inventory.sync', 'compliance.snapshot']); $inventoryRun = OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) - ->where('type', 'inventory_sync') + ->where('type', 'inventory.sync') ->latest('id') ->firstOrFail(); @@ -1546,7 +1556,7 @@ 'outcome' => 'succeeded', ])->save(); - $component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']); + $component->call('startBootstrap', ['inventory.sync', 'compliance.snapshot']); Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1); Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1); @@ -1558,7 +1568,7 @@ $session->refresh(); $runs = $session->state['bootstrap_operation_runs'] ?? []; - expect($runs['inventory_sync'] ?? null)->toBeInt(); + expect($runs['inventory.sync'] ?? null)->toBeInt(); expect($runs['compliance.snapshot'] ?? null)->toBeInt(); }); @@ -1592,7 +1602,7 @@ 'tenant_id' => (int) $tenant->getKey(), 'user_id' => (int) $user->getKey(), 'initiator_name' => $user->name, - 'type' => 'inventory_sync', + 'type' => 'inventory.sync', 'status' => 'queued', 'outcome' => 'pending', 'run_identity_hash' => sha1('busy-'.(string) $connection->getKey()), diff --git a/apps/platform/tests/Feature/Monitoring/AuditCoverageOperationsTest.php b/apps/platform/tests/Feature/Monitoring/AuditCoverageOperationsTest.php index edffde55..8293345c 100644 --- a/apps/platform/tests/Feature/Monitoring/AuditCoverageOperationsTest.php +++ b/apps/platform/tests/Feature/Monitoring/AuditCoverageOperationsTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use App\Models\AuditLog; +use App\Models\OperationRun; +use App\Services\Evidence\Sources\OperationsSummarySource; use App\Services\OperationRunService; use App\Support\Audit\AuditOutcome; use App\Support\OperationRunOutcome; @@ -15,7 +17,7 @@ $run = $service->ensureRunWithIdentity( tenant: $tenant, - type: 'inventory_sync', + type: 'inventory.sync', identityInputs: ['selection_hash' => 'abc123'], context: ['selection_hash' => 'abc123'], initiator: $user, @@ -46,7 +48,7 @@ ->and((string) $audit?->resource_id)->toBe((string) $run->getKey()) ->and($audit?->normalizedOutcome())->toBe(AuditOutcome::Success) ->and($audit?->actorDisplayLabel())->toBe($user->name) - ->and(data_get($audit?->metadata, 'operation_type'))->toBe('inventory_sync'); + ->and(data_get($audit?->metadata, 'operation_type'))->toBe('inventory.sync'); }); it('writes blocked terminal audit semantics for blocked runs', function (): void { @@ -80,3 +82,23 @@ ->and($audit?->normalizedOutcome())->toBe(AuditOutcome::Blocked) ->and(data_get($audit?->metadata, 'failure_summary.0.reason_code'))->toBe('intune_rbac.not_configured'); }); + +it('emits canonical operation types in operations evidence summaries for historical rows', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'type' => 'inventory_sync', + 'outcome' => OperationRunOutcome::Failed->value, + 'created_at' => now()->subMinute(), + ]); + + $payload = app(OperationsSummarySource::class)->collect($tenant); + $entry = data_get($payload, 'summary_payload.entries.0'); + + expect($entry)->toBeArray() + ->and($entry['operation_type'] ?? null)->toBe('inventory.sync') + ->and($entry['type'] ?? null)->toBe('inventory_sync'); +}); diff --git a/apps/platform/tests/Feature/Operations/QueuedExecutionContractMatrixTest.php b/apps/platform/tests/Feature/Operations/QueuedExecutionContractMatrixTest.php index a30b3519..14cbb645 100644 --- a/apps/platform/tests/Feature/Operations/QueuedExecutionContractMatrixTest.php +++ b/apps/platform/tests/Feature/Operations/QueuedExecutionContractMatrixTest.php @@ -74,7 +74,7 @@ function () use (&$terminalInvoked): string { ->and($run->context['reason_code'] ?? null)->toBe('missing_capability') ->and($run->context['execution_legitimacy']['metadata']['required_capability'] ?? null)->toBe(Capabilities::PROVIDER_RUN); })->with([ - 'provider inventory sync' => ['inventory_sync', ProviderInventorySyncJob::class], + 'provider inventory sync' => ['inventory.sync', ProviderInventorySyncJob::class], 'provider compliance snapshot' => ['compliance.snapshot', ProviderComplianceSnapshotJob::class], ]); @@ -139,7 +139,7 @@ function () use (&$terminalInvoked): string { $run = app(OperationRunService::class)->ensureRunWithIdentityStrict( tenant: $tenant, - type: 'backup_schedule_run', + type: 'backup.schedule.execute', identityInputs: [ 'backup_schedule_id' => (int) $schedule->getKey(), 'scheduled_for' => now()->toDateTimeString(), @@ -190,7 +190,7 @@ function () use (&$terminalInvoked): string { $scheduleRun = app(OperationRunService::class)->ensureRunWithIdentityStrict( tenant: $tenant, - type: 'backup_schedule_run', + type: 'backup.schedule.execute', identityInputs: [ 'backup_schedule_id' => 99, 'scheduled_for' => now()->toDateTimeString(), @@ -211,7 +211,7 @@ function () use (&$terminalInvoked): string { $result = app(ProviderOperationStartGate::class)->start( tenant: $tenant, connection: $connection, - operationType: 'inventory_sync', + operationType: 'inventory.sync', dispatcher: static fn (OperationRun $run): null => null, initiator: $user, ); diff --git a/apps/platform/tests/Feature/Operations/RunInventorySyncExecutionReauthorizationTest.php b/apps/platform/tests/Feature/Operations/RunInventorySyncExecutionReauthorizationTest.php index 4ffce3ab..45895ed1 100644 --- a/apps/platform/tests/Feature/Operations/RunInventorySyncExecutionReauthorizationTest.php +++ b/apps/platform/tests/Feature/Operations/RunInventorySyncExecutionReauthorizationTest.php @@ -43,7 +43,7 @@ function runQueuedInventoryJobThroughMiddleware(object $job, Closure $terminal): $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'inventory_sync') + ->where('type', 'inventory.sync') ->latest('id') ->first(); diff --git a/apps/platform/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php b/apps/platform/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php index 86d51a4b..a4617c93 100644 --- a/apps/platform/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php +++ b/apps/platform/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php @@ -15,3 +15,9 @@ expect(OperationCatalog::label('rbac.health_check')) ->toBe('RBAC health check'); })->group('ops-ux'); + +it('does not normalize unknown values to nearby canonical operation labels', function (): void { + expect(OperationCatalog::label('inventory-sync')) + ->toBe('Unknown operation') + ->not->toBe(OperationCatalog::label('inventory.sync')); +})->group('ops-ux'); diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php b/apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php index 6edf24e0..8e0dc0f1 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php @@ -37,7 +37,7 @@ $inventoryRun = OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) - ->where('type', 'inventory_sync') + ->where('type', 'inventory.sync') ->latest('id') ->first(); diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php b/apps/platform/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php index 113ee36b..53e6eb12 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php @@ -43,7 +43,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'inventory_sync') + ->where('type', 'inventory.sync') ->latest('id') ->first(); @@ -59,7 +59,7 @@ expect(OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'inventory_sync') + ->where('type', 'inventory.sync') ->count())->toBe(1); Queue::assertPushed(ProviderInventorySyncJob::class, 1); @@ -96,7 +96,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'inventory_sync') + ->where('type', 'inventory.sync') ->latest('id') ->first(); @@ -189,7 +189,7 @@ $inventoryRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'inventory_sync') + ->where('type', 'inventory.sync') ->latest('id') ->first(); diff --git a/apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php b/apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php index 4431f75e..329d274b 100644 --- a/apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php +++ b/apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php @@ -57,7 +57,7 @@ 'tenant_id' => (int) $tenant->getKey(), 'user_id' => (int) $user->getKey(), 'initiator_name' => $user->name, - 'type' => 'inventory_sync', + 'type' => 'inventory.sync', 'status' => 'queued', 'outcome' => 'pending', 'run_identity_hash' => sha1('busy-onboarding-'.(string) $connection->getKey()), @@ -141,7 +141,7 @@ $inventoryRun = OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) - ->where('type', 'inventory_sync') + ->where('type', 'inventory.sync') ->latest('id') ->firstOrFail(); @@ -155,7 +155,7 @@ 'outcome' => 'succeeded', ])->save(); - $component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']); + $component->call('startBootstrap', ['inventory.sync', 'compliance.snapshot']); expect(OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) diff --git a/apps/platform/tests/Unit/Onboarding/OnboardingDraftResolverTest.php b/apps/platform/tests/Unit/Onboarding/OnboardingDraftResolverTest.php index 90d64075..04804366 100644 --- a/apps/platform/tests/Unit/Onboarding/OnboardingDraftResolverTest.php +++ b/apps/platform/tests/Unit/Onboarding/OnboardingDraftResolverTest.php @@ -197,3 +197,38 @@ expect($drafts->modelKeys())->toBe([(int) $unlinkedDraft->getKey()]); }); + +it('resolves drafts that still contain legacy bootstrap operation state for read-side normalization', function (): void { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'status' => Tenant::STATUS_ONBOARDING, + ]); + $user = User::factory()->create(); + + createUserWithTenant( + tenant: $tenant, + user: $user, + role: 'owner', + workspaceRole: 'owner', + ensureDefaultMicrosoftProviderConnection: false, + ); + + $draft = createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $tenant, + 'started_by' => $user, + 'updated_by' => $user, + 'state' => [ + 'bootstrap_operation_types' => ['inventory_sync', 'compliance.snapshot'], + 'bootstrap_operation_runs' => ['inventory_sync' => 12345], + ], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $resolved = app(OnboardingDraftResolver::class)->resolve($draft->getKey(), $user, $workspace); + + expect($resolved->state['bootstrap_operation_types'] ?? null)->toBe(['inventory_sync', 'compliance.snapshot']) + ->and($resolved->state['bootstrap_operation_runs'] ?? null)->toBe(['inventory_sync' => 12345]); +}); diff --git a/apps/platform/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php b/apps/platform/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php index 492fc765..3d28e404 100644 --- a/apps/platform/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php +++ b/apps/platform/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php @@ -43,7 +43,7 @@ 'execution_prerequisites' => 'not_applicable', ]) ->and($decision->toArray())->toMatchArray([ - 'operation_type' => 'inventory_sync', + 'operation_type' => 'inventory.sync', 'authority_mode' => 'actor_bound', 'allowed' => true, 'retryable' => false, diff --git a/apps/platform/tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php b/apps/platform/tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php new file mode 100644 index 00000000..c5cdddac --- /dev/null +++ b/apps/platform/tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php @@ -0,0 +1,47 @@ +definitions() as $key => $definition) { + $operationType = (string) ($definition['operation_type'] ?? ''); + $resolution = OperationCatalog::resolve($operationType); + + expect($key)->toBe($operationType) + ->and($resolution->aliasStatus)->toBe('canonical') + ->and($resolution->canonical->canonicalCode)->toBe($operationType); + } +}); + +it('declares provider bindings with canonical operation types only', function (): void { + $registry = app(ProviderOperationRegistry::class); + + foreach ($registry->providerBindings() as $key => $bindings) { + expect(OperationCatalog::resolve($key)->aliasStatus)->toBe('canonical'); + + foreach ($bindings as $binding) { + $operationType = (string) ($binding['operation_type'] ?? ''); + $resolution = OperationCatalog::resolve($operationType); + + expect($operationType)->toBe($key) + ->and($resolution->aliasStatus)->toBe('canonical') + ->and($resolution->canonical->canonicalCode)->toBe($operationType); + } + } +}); + +it('rejects legacy aliases as provider registry keys', function (): void { + $registry = app(ProviderOperationRegistry::class); + + expect($registry->isAllowed('inventory_sync'))->toBeFalse() + ->and($registry->isAllowed('entra_group_sync'))->toBeFalse() + ->and($registry->isAllowed('directory_role_definitions.sync'))->toBeFalse() + ->and($registry->isAllowed('inventory.sync'))->toBeTrue() + ->and($registry->isAllowed('directory.groups.sync'))->toBeTrue() + ->and($registry->isAllowed('directory.role_definitions.sync'))->toBeTrue(); +}); diff --git a/apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php b/apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php index db3fc98c..3751c609 100644 --- a/apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php +++ b/apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php @@ -110,7 +110,7 @@ $blocking = OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), - 'type' => 'inventory_sync', + 'type' => 'inventory.sync', 'status' => 'queued', 'context' => [ 'provider_connection_id' => (int) $connection->getKey(), @@ -221,11 +221,11 @@ $result = $gate->start( tenant: $tenant, connection: $connection, - operationType: 'entra_group_sync', + operationType: 'directory.groups.sync', dispatcher: function (OperationRun $run) use (&$dispatched): void { $dispatched++; - expect($run->type)->toBe('entra_group_sync'); + expect($run->type)->toBe('directory.groups.sync'); }, ); @@ -257,7 +257,7 @@ $blocking = OperationRun::factory()->create([ 'tenant_id' => $tenant->getKey(), - 'type' => 'inventory_sync', + 'type' => 'inventory.sync', 'status' => 'running', 'context' => [ 'provider_connection_id' => (int) $connection->getKey(), @@ -281,6 +281,28 @@ expect($result->run->getKey())->toBe($blocking->getKey()); }); +it('rejects legacy aliases before starting provider operations', function (): void { + $tenant = Tenant::factory()->create(); + $connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => 'directory-entra-tenant-id', + 'consent_status' => 'granted', + ]); + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + ]); + + $gate = app(ProviderOperationStartGate::class); + + expect(fn () => $gate->start( + tenant: $tenant, + connection: $connection, + operationType: 'entra_group_sync', + dispatcher: fn () => null, + ))->toThrow(\InvalidArgumentException::class); +}); + it('blocks provider starts when no explicit provider binding supports the connection provider', function (): void { $tenant = Tenant::factory()->create(); $connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([ diff --git a/apps/platform/tests/Unit/Support/OperationRunTypeCanonicalContractTest.php b/apps/platform/tests/Unit/Support/OperationRunTypeCanonicalContractTest.php new file mode 100644 index 00000000..9715e530 --- /dev/null +++ b/apps/platform/tests/Unit/Support/OperationRunTypeCanonicalContractTest.php @@ -0,0 +1,45 @@ +value)->toBe('baseline.capture') + ->and(OperationRunType::BaselineCompare->value)->toBe('baseline.compare') + ->and(OperationRunType::InventorySync->value)->toBe('inventory.sync') + ->and(OperationRunType::DirectoryGroupsSync->value)->toBe('directory.groups.sync') + ->and(OperationRunType::BackupScheduleExecute->value)->toBe('backup.schedule.execute') + ->and(OperationRunType::BackupScheduleRetention->value)->toBe('backup.schedule.retention') + ->and(OperationRunType::BackupSchedulePurge->value)->toBe('backup.schedule.purge') + ->and(OperationRunType::DirectoryRoleDefinitionsSync->value)->toBe('directory.role_definitions.sync'); +}); + +it('keeps enum canonicalCode as a no-op compatibility shim', function (): void { + foreach (OperationRunType::cases() as $case) { + expect($case->canonicalCode())->toBe($case->value); + } +}); + +it('does not expose legacy raw aliases as enum values', function (): void { + expect(OperationRunType::values())->not->toContain( + 'baseline_capture', + 'baseline_compare', + 'inventory_sync', + 'entra_group_sync', + 'backup_schedule_run', + 'backup_schedule_retention', + 'backup_schedule_purge', + 'directory_role_definitions.sync', + ); +}); + +it('keeps every enum value in the operation catalog as current canonical truth', function (): void { + foreach (OperationRunType::values() as $type) { + $resolution = OperationCatalog::resolve($type); + + expect($resolution->aliasStatus)->toBe('canonical') + ->and($resolution->canonical->canonicalCode)->toBe($type); + } +}); diff --git a/apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php b/apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php index 2e510fbd..7432e43a 100644 --- a/apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php +++ b/apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php @@ -43,6 +43,29 @@ ->and(OperationRunType::BackupSetUpdate->canonicalCode())->toBe('backup_set.update'); }); +it('keeps legacy aliases as read-side compatibility only', function (): void { + $aliasInventory = OperationCatalog::aliasInventory(); + + foreach ($aliasInventory as $rawValue => $metadata) { + if (($metadata['alias_status'] ?? null) !== 'legacy_alias') { + continue; + } + + expect($metadata['write_allowed'] ?? null) + ->toBeFalse("Legacy alias [{$rawValue}] must not be write-time truth."); + } +}); + +it('keeps unknown operation values explicitly unknown', function (): void { + $resolution = OperationCatalog::resolve('inventory-sync'); + + expect($resolution->canonical->canonicalCode)->toBe('inventory-sync') + ->and($resolution->canonical->displayLabel)->toBe('Unknown operation') + ->and($resolution->aliasStatus)->toBe('unknown') + ->and($resolution->wasLegacyAlias)->toBeFalse() + ->and($resolution->aliasesConsidered)->toBe([]); +}); + it('publishes contributor-facing operation inventories and alias retirement metadata', function (): void { $descriptor = OperationCatalog::ownershipDescriptor(); $canonicalInventory = OperationCatalog::canonicalInventory(); @@ -53,4 +76,4 @@ ->and($canonicalInventory['baseline.compare']['display_label'] ?? null)->toBe('Baseline compare') ->and($aliasInventory['baseline_compare']['canonical_name'] ?? null)->toBe('baseline.compare') ->and($aliasInventory['baseline_compare']['retirement_path'] ?? null)->toContain('baseline.compare'); -}); \ No newline at end of file +}); diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index c6f89b2c..b1a1ce28 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -66,6 +66,20 @@ ### R1.9 Platform Localization v1 (DE/EN) **Active specs**: — (not yet specced) +### Product Scalability & Self-Service Foundation +Self-service and supportability foundation that keeps TenantPilot operable as a low-headcount, AI-assisted SaaS instead of drifting into manual onboarding, manual support, and founder-dependent customer operations. +**Goal**: Productize the recurring work around onboarding, diagnostics, support context, plan limits, and customer guidance so that new customers can evaluate, onboard, operate, and request help with minimal manual intervention. + +- Self-Service Tenant Onboarding & Connection Readiness: guided tenant setup, consent readiness, provider connection checks, permission diagnostics, setup progress, completion score, and concrete next actions +- Support Diagnostic Pack: diagnostic bundles for workspace, tenant, OperationRun, Finding, ProviderConnection, and report contexts with relevant health state, permissions, run context, errors, audit references, and AI-readable summaries +- In-App Support Request with Context: support entry points that attach workspace, tenant, run/finding/report references, severity, diagnostic pack reference, and ticket reference back into TenantPilot +- Product Knowledge & Contextual Help: help registry for feature explanations, status meanings, error guidance, permission rationale, troubleshooting hints, and docs links; also usable as the source layer for later AI support +- Plans, Entitlements & Billing Readiness: plan model, feature gates, tenant/workspace/user/report/export/retention limits, trial state, grace periods, billing status, and audited plan changes +- Demo & Trial Readiness: seeded demo workspaces, sample tenants, sample baselines/findings/reports, demo reset support, trial provisioning checklist, and sample-data mode where appropriate +- Customer-facing transparency hooks: product surfaces should be designed so customer read-only views, review workspaces, support requests, and review-pack downloads can reuse the same underlying entities instead of becoming parallel one-off features + +**Active specs**: — (not yet specced) + --- ## Planned (Next Quarter) @@ -99,6 +113,9 @@ ### R2 Completion — Evidence & Exception Workflows - Formal evidence/review-pack entity foundation → Spec 153 (evidence snapshots, draft) + Spec 155 (tenant review layer / review packs, draft) - Workspace-level PII override for review packs → deferred from 109 - Customer Review Workspace / Read-only View v1 → sharpen customer-facing review consumption: baseline status, latest reviews, findings, accepted risks, evidence/review-pack downloads, customer-safe redaction, and no admin/remediation actions +- Support Diagnostic Pack → connect tenant/review/finding/report/operation contexts into a reusable support bundle before support demand scales +- In-App Support Request with Context → attach the relevant diagnostic pack and ticket reference to support workflows without creating a separate support data model +- Product Knowledge & Contextual Help → reuse canonical glossary, outcome/reason semantics, and report/finding terminology as the product-help source layer ### Findings Workflow v2 / Execution Layer Turn findings from a reviewable register into an accountable operating flow with clear ownership, personal queues, intake, hygiene, and minimal escalation. @@ -119,10 +136,57 @@ ### Platform Operations Maturity - Raw error/context drilldowns for system console (deferred from Spec 114) - Multi-workspace operator selection in `/system` (deferred from Spec 113) +### Solo-Founder SaaS Automation & Operating Readiness +Internal operating-system track for running TenantPilot as a lean, AI-assisted SaaS company with repeatable customer acquisition, onboarding, support, billing, security review, and release communication. +**Goal**: Keep company operations from becoming founder-only manual work while the product moves from pilots to repeatable customer delivery. + +- Lead Capture & CRM Pipeline: website lead forms, demo requests, waitlist, lead status, pilot status, customer status, follow-up reminders, and meeting notes capture +- Demo Environment Automation: repeatable demo workspace, sample tenant data, reset flow, demo scripts, and separate demo stories for MSP and enterprise IT buyers +- Trial Provisioning Workflow: trial request intake, plan/limit assignment, provisioning checklist, onboarding status, trial expiry, conversion path, and grace handling +- Billing & Contract Readiness: plan matrix, offer templates, invoicing flow, payment/billing status, trial-to-paid process, cancellation process, and grace-period handling +- AVV / DPA / TOM / Legal Pack: reusable customer-facing legal and data-processing artifacts aligned with the actual product data model and hosting setup +- Security Trust Pack Light: hosting overview, data categories, least-privilege permission model, RBAC model, retention, backup, audit logging, subprocessors, and “what we do not store” documentation +- Support Desk + AI Triage: support mailbox or ticket system, categories, priorities, macros, known issues, AI triage, answer drafts, and linkage to TenantPilot diagnostic packs +- Knowledge Base Pipeline: public docs, onboarding docs, troubleshooting docs, internal runbooks, and a maintained source set for AI-assisted support +- Monitoring & Incident Runbooks: uptime, queues, failed jobs, error tracking, backups, storage, certificates, Graph failure rates, status page, incident templates, postmortem templates, and customer communication templates +- Release & Customer Communication Automation: customer changelog, release notes, support notes, migration notes, breaking-change markers, known limitations, and docs-update checklist + +**Active specs**: — (not yet specced; company-ops track, not all items need product specs) + +### Additional Solo-Founder Scale Guardrails +Cross-cutting operating guardrails that prevent TenantPilot from scaling through hidden manual work, unclear customer health, missing operational controls, ad-hoc customer communication, or unmanaged founder dependency. +**Goal**: Make repeatability, observability, controllability, and delegability explicit before customer volume makes the gaps expensive. + +- Product Usage & Adoption Telemetry: privacy-aware usage signals for onboarding completion, feature adoption, report exports, failed flows, support-triggering surfaces, inactive customers, and trial conversion indicators +- Customer Health Score: derived customer/workspace health indicators from login/activity, provider health, last sync, baseline compare freshness, open high findings, overdue SLAs, expiring risk acceptances, failed runs, support load, and review-pack readiness +- Operational Controls & Feature Flags: global/workspace kill switches and scoped controls for risky features, restore actions, exports, AI functions, provider actions, trials, maintenance scenarios, and temporary read-only states +- Customer Lifecycle Communication: structured lifecycle messages for welcome, onboarding, trial reminders, provider health warnings, review-pack readiness, risk-expiry reminders, release updates, incidents, renewal, payment issues, and churn feedback +- Vendor Questionnaire Answer Bank: reusable security/procurement answers aligned with the Security Trust Pack, product data model, Microsoft permissions, hosting, AI usage, subprocessors, retention, backup, deletion, and incident handling +- Product Intake & No-Customization Governance: feature-request intake, roadmap-fit classification, no-custom-work policy, customer exception handling, productization rules, and a clear path from request → candidate → spec → release or rejection +- Support Severity Matrix & Runbooks: P1–P4 definitions, incident vs support vs bug vs feature request distinction, response expectations by plan, escalation rules, known-issue handling, and internal support runbooks +- Data Retention, Export & Deletion Self-Service: customer-facing and operator-facing flows for export, archive, deletion request handling, trial data expiry, workspace deactivation, and evidence/report retention visibility +- Business Continuity / Founder Backup Plan: access documentation, secret management, emergency contacts, deployment and restore runbooks, incident templates, DNS/domain/hosting ownership, billing access, and vacation/sickness fallback + +**Active specs**: — (not yet specced; guardrail track, only product-impacting items should become specs) + --- ## Mid-term (2–3 Quarters) +### Product Usage, Customer Health & Operational Controls +Product-side implementation lane for the highest-impact solo-founder guardrails: adoption telemetry, customer/workspace health scoring, and operator controls/feature flags. +**Goal**: Give the founder/operator a measurable, controllable view of customer adoption, risk, and operational safety without relying on manual checks across logs, support tools, billing tools, and product screens. +**Why it matters**: Low-headcount SaaS only works if the product shows where customers are stuck, which workspaces are unhealthy, and which features can be safely paused or limited during incidents. +**Depends on**: Self-Service Tenant Onboarding & Connection Readiness, Support Diagnostic Pack, Plans / Entitlements & Billing Readiness, ProviderConnection health, OperationRun truth, Findings workflow, StoredReports / EvidenceItems, and audit log foundation. +**Scope direction**: Start with privacy-aware product telemetry, derived customer/workspace health indicators, and a minimal operational controls registry. Avoid building a full analytics platform, CRM, or customer-success suite in the first slice. + +### AI-Assisted Customer Operations +AI-assisted customer operations layer for support, reviews, summaries, release communication, and customer-facing explanations, explicitly bounded by human approval and product auditability. +**Goal**: Use AI to prepare, summarize, classify, and draft customer operations work while keeping tenant-changing actions, customer commitments, legal statements, and external communications under human approval. +**Why it matters**: TenantPilot can stay lean only if support, customer reviews, release communication, and diagnostics are structured enough for AI assistance without becoming ungoverned automation. +**Depends on**: Product Knowledge & Contextual Help, Support Diagnostic Pack, In-App Support Request, StoredReports / EvidenceItems, Findings workflow maturity, release-note discipline, and company support-desk structure. +**Scope direction**: Start with AI-generated support summaries, finding explanations, tenant review summaries, diagnostic summaries, release-note drafts, and support-response drafts. Avoid autonomous tenant remediation, automatic risk acceptance, automatic legal commitments, or customer-facing messages without review. + ### Decision-Based Operating Foundations Constitution hardening for decision-first governance, workflow-first navigation, surface taxonomy, and primary-vs-evidence surface refactoring. **Goal**: Prepare TenantPilot for a quieter, decision-centered operating model where primary surfaces ask for action and deeper technical detail stays available on demand. @@ -136,12 +200,24 @@ ### MSP Portfolio & Operations (Multi-Tenant) **Prerequisites**: Decision-Based Operating Foundations, Cross-tenant compare (Spec 043 — draft only). **Later expansion**: portfolio operations should eventually include a cross-tenant findings workboard once tenant-level inbox and intake flows are stable. -### Human-in-the-Loop Autonomous Governance (Decision-Based Operating) -Continuous detection, triage, decision drafting, approval-driven execution, and closed-loop evidence for governance actions across the workspace portfolio. -**Goal**: Reduce operator work from searching and correlating to approving, rejecting, deferring, or time-boxing deviations while TenantPilot handles the mechanical follow-through. -**Why it matters**: This is the longer-term MSP Portfolio OS layer. TenantPilot becomes the decision control plane for accountable governance, not just a browser for runs, evidence, and tenant state. -**Depends on**: Decision-Based Operating Foundations, MSP Portfolio & Operations surfaces, drift/findings/exception maturity, actionable alerts with structured payloads, canonical operation/evidence truth. -**Scope direction**: Start with governance inbox + decision packs + actionable alerts. Later add automation policies, guardrails, maintenance windows, dual approval, and before/after evidence automation. Keep human approval and auditability central; avoid blind autopilot remediation. +### Human-in-the-Loop Autonomous Governance (Microsoft-first, Provider-extensible Decision-Based Operating) +Continuous detection, triage, decision drafting, approval-driven execution, and closed-loop evidence for governance actions across the Microsoft-first workspace portfolio, while keeping the decision model provider-extensible for later non-Microsoft domains. +**Goal**: Move TenantPilot from Microsoft tenant search-and-troubleshoot workflows to decision-based governance operations. Customers and operators should not have to click through tenants, runs, findings, reports, logs, and evidence to discover what needs attention. TenantPilot should detect relevant work, gather context, summarize impact, propose safe next actions, and present decision-ready items. The first product scope stays Microsoft tenant governance; the underlying decision model should avoid hard-coding Microsoft-only assumptions where a provider-neutral abstraction is already available. +**Why it matters**: This is the longer-term MSP Portfolio OS layer. TenantPilot becomes the decision control plane for accountable Microsoft tenant governance first, not just a browser for runs, evidence, and tenant state. Detail pages remain available as evidence and diagnostics, but the default operating model becomes guided decisions, not manual investigation. +**Depends on**: Decision-Based Operating Foundations, Product Knowledge & Contextual Help, Support Diagnostic Pack, Customer Health Score, MSP Portfolio & Operations surfaces, drift/findings/exception maturity, actionable alerts with structured payloads, canonical operation/evidence truth, operational controls, and human approval gates. +**Scope direction**: Start with a Governance Inbox / Action Center, decision items, decision packs, actionable alerts, and approval-gated workflows for Microsoft tenant governance. Later add automation policies, guardrails, maintenance windows, dual approval, before/after evidence automation, and limited remediation execution. Keep human approval and auditability central; avoid blind autopilot remediation. + +**Core workflow**: +- Detect relevant governance work automatically +- Group, deduplicate, and prioritize related signals +- Generate a decision pack with summary, impact, evidence, affected tenants/policies, recommended actions, and confidence +- Present clear actions such as approve, reject, snooze, assign, accept risk, create ticket, run compare, generate review pack, or request evidence +- Require human approval for tenant-changing, customer-facing, or risk-accepting actions +- Execute approved follow-up through OperationRuns or controlled workflows +- Verify outcome and attach before/after evidence +- Keep audit trail across detection, recommendation, approval, execution, and verification + +**Anti-pattern**: Do not make customers manually troubleshoot by navigating through raw runs, logs, tables, and details as the primary workflow. Raw surfaces are evidence and diagnostics, not the main operating model. ### Drift & Change Governance ("Revenue Lever #1") Change approval workflows (DEV→PROD with audit pack), guardrails/policy freeze windows, tamper detection. @@ -226,6 +302,15 @@ ## Infrastructure & Platform Debt | Item | Risk | Status | |------|------|--------| +| No explicit company automation roadmap linkage | Risk that sales, support, billing, legal, and customer communication become founder-only manual work | Covered by Solo-Founder SaaS Automation & Operating Readiness | +| No product-level entitlement foundation yet | Later pricing, trial, retention, export, user, and tenant limits may require invasive retrofits | Covered by Product Scalability & Self-Service Foundation | +| No structured support diagnostic bundle yet | Support cases require manual context gathering across tenants, runs, findings, providers, and reports | Covered by Product Scalability & Self-Service Foundation | +| No formal security trust pack yet | Enterprise sales and customer security reviews require repeated manual explanations | Covered by Solo-Founder SaaS Automation & Operating Readiness | +| No product usage/adoption telemetry yet | Founder cannot see onboarding drop-off, feature adoption, trial health, or support-triggering surfaces without manual investigation | Covered by Additional Solo-Founder Scale Guardrails | +| No customer health score yet | Churn, inactive customers, stale reviews, unhealthy provider connections, and unresolved high-risk findings may be noticed too late | Covered by Additional Solo-Founder Scale Guardrails | +| No explicit operational controls / feature flags lane | Incidents or risky features may require code changes or manual database intervention instead of safe operator controls | Covered by Additional Solo-Founder Scale Guardrails | +| No no-customization governance yet | Customer-specific requests can silently turn the product into consulting work and create hidden maintenance obligations | Covered by Additional Solo-Founder Scale Guardrails | +| No business-continuity / founder-backup plan yet | Solo-founder operations create continuity risk for incidents, illness, vacation, access recovery, and customer trust | Covered by Additional Solo-Founder Scale Guardrails | | No `.env.example` in repo | Onboarding friction | Open | | CI pipeline config status drift | Roadmap debt list may be stale because workflow files exist and should be re-audited; align with static-analysis and architecture-boundary gates | Review needed | | No PHPStan/Larastan | No static analysis; covered by `Static Analysis Baseline for Platform Code` spec candidate | Open | @@ -238,17 +323,26 @@ ## Infrastructure & Platform Debt ## Priority Ranking (from Product Brainstorming) -1. MSP Portfolio + Alerting -2. Drift + Approval Workflows -3. Standardization / Linting -4. Promotion DEV→PROD -5. Recovery Confidence +1. Product Scalability & Self-Service Foundation +2. Product Usage, Customer Health & Operational Controls +3. Decision-Based Operating / Governance Inbox +4. MSP Portfolio + Alerting +5. Drift + Approval Workflows +6. Evidence / Review Packs + Customer Review Workspace +7. Standardization / Linting +8. Promotion DEV→PROD +9. Recovery Confidence +10. Solo-Founder SaaS Automation & Operating Readiness +11. Additional Solo-Founder Scale Guardrails --- ## How to use this file -- **Big themes** live here. +- **Big product and operating themes** live here. - **Concrete spec candidates** → see [spec-candidates.md](spec-candidates.md) +- **Company automation / solo-founder operating items** live here as strategic tracks first; only product-impacting or repeatable engineering work should become spec candidates. +- **Solo-founder guardrails** should remain visible even when they are not immediate product specs, because they define what must become measurable, controllable, delegable, or documented before customer volume grows. +- **Governance positioning is Microsoft-first, provider-extensible**: roadmap language should keep the initial product scope focused on Microsoft tenant governance while avoiding unnecessary Microsoft-only coupling in platform-level abstractions. - **Small discoveries from implementation** → see [discoveries.md](discoveries.md) - **Product principles** → see [principles.md](principles.md) diff --git a/docs/product/spec-candidates.md b/docs/product/spec-candidates.md index 5256dd9d..3c315eb0 100644 --- a/docs/product/spec-candidates.md +++ b/docs/product/spec-candidates.md @@ -5,7 +5,7 @@ # Spec Candidates > > **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec` -> **Last reviewed**: 2026-04-25 (added Codebase Quality & Engineering Maturity cluster from full codebase audit with System Panel Least-Privilege Capability Model, Static Analysis Baseline, Architecture Boundary Guard Tests, Filament Hotspot Decomposition Foundation, and RestoreService Responsibility Split; retained OperationRun UX Consistency and Provider Boundary hardening sequences as current strategic hardening lanes) +> **Last reviewed**: 2026-04-25 (added Product Scalability & Self-Service Foundation candidates and Additional Solo-Founder Scale Guardrails candidates from roadmap: Product Usage & Adoption Telemetry, Customer Health Score, Operational Controls & Feature Flags, Customer Lifecycle Communication, Product Intake & No-Customization Governance, and Data Retention / Export / Deletion Self-Service; retained Codebase Quality & Engineering Maturity cluster and existing strategic hardening lanes) --- @@ -24,6 +24,15 @@ ## Inbox - Workspace-level PII override for review packs (deferred from Spec 109 — controls whether PII is included/redacted in tenant review pack exports at workspace scope) - CSV export for filtered run metadata (deferred from Spec 114 — allow operators to export filtered operation run lists from the system console as CSV) - Raw error/context drilldowns for system console (deferred from Spec 114 — in-product drilldown into raw error payloads and execution context for failed/stuck runs in the system console) +- Lead Capture & CRM Pipeline (company-ops track; not a product spec unless TenantPilot later needs in-product customer lifecycle surfaces) +- Billing & Contract Readiness (company-ops track; product spec only for plan/entitlement/billing-status foundation) +- AVV / DPA / TOM / Legal Pack (company-ops track; source artifacts should align with product data model but are not a product feature by default) +- Support Desk + AI Triage (company-ops track; product spec only where TenantPilot creates support context bundles or in-app support requests) +- Monitoring & Incident Runbooks (company-ops track; product spec only where platform telemetry or customer-facing status integrations are required) +- Release & Customer Communication Automation (company-ops track; product spec only where release metadata/changelog becomes in-product) +- Vendor Questionnaire Answer Bank (company-ops track; generally not a product spec unless answers become customer-facing trust-center content or product-backed compliance evidence) +- Support Severity Matrix & Runbooks (company-ops track; product spec only where severity, SLA, escalation, or incident state becomes modeled in TenantPilot) +- Business Continuity / Founder Backup Plan (company-ops track; not a product spec unless product-side operational controls or customer-facing continuity surfaces are required) --- @@ -64,21 +73,569 @@ ## Qualified > Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung. + > **Current strategic priority — Governance Platform Foundation** > > The next promoted specs should stabilize TenantPilot as a Governance-of-Record platform before expanding into additional Microsoft domains, compliance overlays, or multi-cloud execution. > > Recommended next sequence: > -> 1. **Provider Identity & Target Scope Neutrality** -> 2. **Canonical Operation Type Source of Truth** -> 3. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys** -> 4. **Customer Review Workspace v1** +> 1. **Self-Service Tenant Onboarding & Connection Readiness** +> 2. **Support Diagnostic Pack** +> 3. **Product Usage & Adoption Telemetry** +> 4. **Operational Controls & Feature Flags** +> 5. **Provider Identity & Target Scope Neutrality** +> 6. **Canonical Operation Type Source of Truth** +> 7. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys** +> 8. **Customer Review Workspace v1** > -> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining risk is semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage. +> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining product risk is not only semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage; it is also founder-dependent onboarding/support and lack of product-side observability/control. Self-service onboarding, diagnostic packs, adoption telemetry, and operational controls therefore move ahead of broader expansion so TenantPilot becomes repeatably operable, measurable, and safe to run with low headcount. -> Codebase Quality & Engineering Maturity cluster: these candidates come from the full codebase quality audit on 2026-04-25. The audit classified the repo as **good / product-capable, not bad coding**, but identified a small set of structural risks that should be handled before larger feature expansion: coarse System Panel platform visibility, missing static-analysis gates, thin architecture-boundary enforcement, and several large Filament/service hotspots. This cluster is intentionally hardening-focused; it must not become a broad rewrite or cosmetic cleanup campaign. +> Product Scalability & Self-Service Foundation cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to keep TenantPilot operable as a low-headcount, AI-assisted SaaS by productizing recurring onboarding, support, diagnostics, entitlement, help, demo, and customer-operations work. This cluster should not become a generic backoffice automation program. Only product-impacting or repeatable engineering work belongs here; pure company-ops work stays in the roadmap / operating system track. + +### Self-Service Tenant Onboarding & Connection Readiness +- **Type**: product scalability / onboarding foundation +- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation +- **Problem**: Tenant onboarding, Microsoft consent readiness, provider connection validation, permission diagnostics, and setup guidance can become founder-led manual work if they are not productized. A customer or MSP should not need a live walkthrough for every tenant connection just to understand what is missing, what is healthy, and what the next action is. +- **Why it matters**: TenantPilot cannot scale as a solo-founder or low-headcount SaaS if every pilot, trial, or customer tenant requires manual onboarding support. The product already has ProviderConnection, health, onboarding, operation-run, and permission-related foundations; these need to converge into an operator-facing readiness workflow. +- **Proposed direction**: + - provide guided tenant setup with clear setup steps and completion state + - expose consent readiness and permission diagnostics in product language + - show provider connection health and actionable next steps before deeper governance workflows are used + - distinguish missing consent, missing permissions, unreachable provider, expired credentials, blocked health checks, and not-yet-run checks + - persist or derive an onboarding/readiness status that can be reused by dashboards, support diagnostics, trial flows, and customer review surfaces + - keep provider-specific Microsoft details contextual while preserving the provider-boundary language from the platform hardening lane +- **Scope boundaries**: + - **In scope**: guided onboarding status, readiness checklist, provider connection health summary, permission diagnostics, setup progress, next-action guidance, and tests for readiness semantics + - **Out of scope**: full CRM/trial pipeline, billing activation, broad provider marketplace, custom customer-specific onboarding flows, or autonomous tenant remediation +- **Acceptance points**: + - a new workspace/tenant operator can see which onboarding steps are complete and which are blocking + - missing or insufficient Microsoft permissions produce explicit operator guidance rather than generic failure copy + - provider connection health is visible without requiring raw run/context inspection + - readiness state can be consumed by support diagnostic packs and trial/demo flows + - server-side policies still enforce who can view or manage onboarding state +- **Risks / open questions**: + - Avoid creating a second onboarding model if existing onboarding/session/provider entities can be composed + - Readiness must not become a false-green signal; failed or stale health checks need explicit freshness semantics + - Provider-specific consent details should not leak into generic platform vocabulary as permanent truth +- **Dependencies**: ProviderConnection, managed tenant onboarding workflow, provider health checks, permission/consent diagnostics, OperationRun links, Provider Boundary Hardening +- **Related specs / candidates**: Provider Identity & Target Scope Neutrality, Provider Surface Vocabulary & Descriptor Cleanup, Support Diagnostic Pack, Product Knowledge & Contextual Help +- **Strategic sequencing**: First item in this product-scalability cluster because it directly reduces manual onboarding and supports trials, demos, support, and customer transparency. +- **Priority**: high + +### Support Diagnostic Pack +- **Type**: product scalability / supportability foundation +- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation +- **Problem**: Support cases currently risk requiring manual context gathering across workspace, tenant, ProviderConnection, OperationRun, Finding, Report, Evidence, and audit surfaces. Without a reusable diagnostic bundle, every support request becomes an investigation task before the actual issue can be addressed. +- **Why it matters**: A low-headcount SaaS needs support context to be captured by the product, not reconstructed by the founder. Diagnostic packs also create the safe input layer for later AI-assisted support summaries and triage without granting an AI or support user broad ad-hoc access to everything. +- **Proposed direction**: + - define a support diagnostic bundle contract for workspace, tenant, OperationRun, Finding, ProviderConnection, StoredReport, and review-pack contexts + - include relevant health state, latest operation links, failure reason codes, permission/connection state, freshness, artifact references, audit references, and redacted operator summaries + - provide an AI-readable but customer-safe summary shape that can be attached to support requests + - keep raw sensitive payloads out of the default pack unless explicitly authorized + - model redaction and access checks as first-class behavior + - allow diagnostic packs to be referenced from in-app support requests and internal support workflows +- **Scope boundaries**: + - **In scope**: diagnostic pack contract, context collectors, redaction rules, support-safe summary generation, access policy, references to runs/findings/reports/evidence, and tests + - **Out of scope**: external ticket-system integration, support desk implementation, AI chat bot, broad log export, customer-visible trust center, or unrestricted raw payload download +- **Acceptance points**: + - a diagnostic pack can be generated for at least tenant and OperationRun contexts + - pack contents are deterministic, scoped, and redacted according to caller capability + - the pack links to canonical OperationRun/report/finding/evidence records instead of duplicating truth + - sensitive raw provider payloads are excluded by default + - tests prove unauthorized users cannot generate packs for unrelated workspaces/tenants +- **Risks / open questions**: + - Over-including raw context could create data-leak or compliance risk + - Under-including context would make the pack less useful and push operators back to manual investigation + - The product needs a clear capability boundary, likely related to `platform.support_diagnostics.view` and tenant/workspace support permissions +- **Dependencies**: OperationRun link contract, StoredReports / EvidenceItems, Findings workflow, ProviderConnection health, audit log foundation, System Panel least-privilege model +- **Related specs / candidates**: In-App Support Request with Context, AI-Assisted Customer Operations, System Panel Least-Privilege Capability Model, OperationRun Start UX Contract +- **Strategic sequencing**: Second item after Self-Service Tenant Onboarding; it should land before support volume grows and before AI support triage is introduced. +- **Priority**: high + +### In-App Support Request with Context +- **Type**: product scalability / support workflow +- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation +- **Problem**: A generic support email or external ticket link loses the most important product context: workspace, tenant, operation, finding, report, evidence, severity, and current diagnostic state. This creates avoidable back-and-forth and makes support impossible to automate cleanly. +- **Why it matters**: If TenantPilot is meant to scale with minimal staff, support requests must be structured at the moment they are created. The product should attach the right context automatically instead of relying on customers to describe technical state manually. +- **Proposed direction**: + - add context-aware support request entry points on selected high-value surfaces + - attach workspace, tenant, OperationRun, Finding, ProviderConnection, StoredReport, or review-pack references automatically + - attach or reference a Support Diagnostic Pack when available + - capture severity, customer-facing message, optional reproduction notes, and contact metadata + - create an internal support reference or external ticket reference when configured + - emit an audit event for support request creation where appropriate +- **Scope boundaries**: + - **In scope**: in-product support request model or outbound adapter seam, context attachment, diagnostic-pack reference, ticket reference field, audit event, capability checks, and first adoption on one or two critical surfaces + - **Out of scope**: full helpdesk product, two-way ticket sync, SLA engine, AI support bot, CRM pipeline, or broad customer success automation +- **Acceptance points**: + - support request created from a run/finding/tenant surface carries the relevant context without manual copy-paste + - request creation respects workspace/tenant authorization + - diagnostic pack attachment/reference is capability- and redaction-aware + - support request status or ticket reference can be shown back in the product where useful + - tests prove unrelated tenant context cannot be attached accidentally +- **Risks / open questions**: + - Decide whether the first version stores support requests in TenantPilot, sends them outbound only, or supports both via an adapter seam + - Avoid coupling the product to one helpdesk provider too early + - Ensure support request creation does not expose internal-only diagnostic content to customer members +- **Dependencies**: Support Diagnostic Pack, audit log foundation, notification/ticket-ref patterns, Customer Review Workspace v1 if customer users can create requests +- **Related specs / candidates**: Support Diagnostic Pack, PSA/Ticketing v1, Customer Review Workspace v1, AI-Assisted Customer Operations +- **Strategic sequencing**: Third item in this cluster; should follow or minimally depend on the diagnostic-pack contract. +- **Priority**: high + +### Product Knowledge & Contextual Help +- **Type**: product scalability / operator guidance / support reduction +- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation +- **Problem**: Statuses, findings, drift states, permission requirements, risk acceptance, evidence gaps, and operation outcomes can require founder explanation if the product does not provide contextual help. Existing glossary and reason-code work creates the vocabulary foundation, but not a structured product-help layer. +- **Why it matters**: Every unclear state becomes a support ticket, onboarding call, or sales objection. A product knowledge layer also becomes the maintained source for public docs, support macros, AI support summaries, and customer-facing explanations. +- **Proposed direction**: + - introduce a contextual help registry keyed by feature, surface, status, reason code, and action where appropriate + - reuse canonical glossary and reason-code translation semantics instead of inventing local help copy + - provide operator-facing explanations for common states such as drift, limited confidence, risk accepted, evidence gap, blocked run, stale run, missing permission, and connection unhealthy + - support docs links, troubleshooting hints, and safe next actions + - keep machine/audit/export semantics invariant and avoid localizing core identifiers + - make the registry usable by later AI-assisted customer operations as a trusted knowledge source +- **Scope boundaries**: + - **In scope**: help registry, first high-value surface integrations, glossary/reason-code linkage, docs-link structure, troubleshooting snippets, and tests for missing/invalid keys where useful + - **Out of scope**: full public documentation site, AI chatbot, complete localization overhaul, legal/compliance claims, or rewriting every help text in the product +- **Acceptance points**: + - at least two critical surfaces consume contextual help from the registry instead of local hardcoded explanations + - help copy references canonical terminology for findings, baseline, drift, risk acceptance, evidence, and operation outcomes + - missing help keys fail predictably or degrade gracefully + - the registry can expose a machine-readable source set for future AI support without including secrets or customer data + - help content is reviewable and versionable as product knowledge, not scattered UI prose +- **Risks / open questions**: + - Too much help text can make enterprise UI noisy; progressive disclosure is required + - Help registry should not become a second source of truth for status semantics + - Localization and terminology governance need a clear boundary with Platform Localization v1 +- **Dependencies**: Platform Vocabulary Glossary, Operator Reason Code Translation, Governance Friction & Operator Vocabulary Hardening, Platform Localization v1 direction +- **Related specs / candidates**: AI-Assisted Customer Operations, Self-Service Tenant Onboarding & Connection Readiness, Support Diagnostic Pack, Baseline Compare Scope Guardrails & Ambiguity Guidance +- **Strategic sequencing**: Can run in parallel with support diagnostics, but should land before AI-generated customer explanations. +- **Priority**: high + +### Plans, Entitlements & Billing Readiness +- **Type**: product architecture / commercial scalability foundation +- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation +- **Problem**: TenantPilot needs a product-level way to express plan limits, feature gates, trial/grace status, workspace/tenant/user/report/export/retention limits, and billing state before real customer growth. Without an entitlement foundation, pricing and packaging decisions later require invasive retrofits across RBAC, exports, retention, reports, tenant counts, and customer views. +- **Why it matters**: A SaaS cannot scale cleanly if commercial packaging is implemented as scattered conditionals or manual founder decisions. Entitlements are not just billing; they are product behavior, support behavior, trial behavior, and customer expectation management. +- **Proposed direction**: + - introduce plan and entitlement primitives at workspace/account scope + - model feature gates and quantitative limits separately + - support trial, active, grace, suspended/read-only, and canceled billing states where appropriate + - define enforcement points for tenants, users, exports, retention, reports, evidence packs, and advanced governance features + - audit plan changes and entitlement overrides + - keep external billing-provider integration behind an adapter seam and out of the initial foundation if needed +- **Scope boundaries**: + - **In scope**: plan model, entitlement model, feature-gate checks, limit checks, trial/grace/billing status, audit events, first enforcement points, and tests + - **Out of scope**: full Stripe integration, payment collection UI, invoice rendering, accounting integration, tax automation, custom enterprise contract engine, or public pricing page +- **Acceptance points**: + - workspace/account has a resolved plan and entitlement set + - feature gates and numeric limits can be checked through a central service instead of scattered conditionals + - trial and grace states influence product access in a predictable and tested way + - plan changes and overrides are audited + - at least one real product limit is enforced through the entitlement service +- **Risks / open questions**: + - Premature pricing complexity could slow product discovery; start with simple plans and explicit overrides + - Enterprise contracts may require manual overrides, but those overrides must remain auditable + - Read-only/suspended behavior must be carefully designed so customers do not lose access to evidence or audit history unexpectedly +- **Dependencies**: workspace/account model, RBAC/capabilities, audit log foundation, retention/export/report features, Customer Review Workspace direction +- **Related specs / candidates**: Customer Review Workspace v1, Review Pack export, Evidence domain, Security Trust Pack Light +- **Strategic sequencing**: High priority before broader customer onboarding and paid trials, but can be implemented as a foundation slice without full billing integration. +- **Priority**: high + +### Demo & Trial Readiness +- **Type**: product scalability / sales enablement foundation +- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation and Solo-Founder SaaS Automation track +- **Problem**: Demos and trials become manual work if the product cannot provide repeatable demo data, resettable demo workspaces, realistic sample baselines/findings/reports, and a clear trial provisioning path. Without this, sales conversations depend on live manual setup or fragile local data. +- **Why it matters**: A solo-founder SaaS needs demos and trials to be repeatable. TenantPilot's value is easier to understand when buyers can see baselines, drift, findings, risk acceptance, evidence packs, and reviews without waiting for a real tenant to produce all states naturally. +- **Proposed direction**: + - create a demo workspace/sample data mode with seeded tenants, baselines, findings, review packs, evidence, and operation history + - provide a reset flow or safe reseed process for demo environments + - define demo stories for MSP buyers and enterprise IT buyers + - create a trial provisioning checklist that ties into onboarding/readiness and plan/entitlement state + - keep demo data clearly marked so it never mixes with production customer truth +- **Scope boundaries**: + - **In scope**: demo seed data, demo reset support, sample governance artifacts, trial readiness checklist, demo-mode indicators, and tests for data separation + - **Out of scope**: CRM pipeline, public signup flow, payment collection, marketing website, fully automated self-serve provisioning, or fake provider execution pretending to be real tenant truth +- **Acceptance points**: + - demo environment can be prepared repeatably without manual database editing + - sample data covers at least baseline, drift/finding, risk acceptance, evidence/report, and operation-run stories + - demo/sample data is visibly marked and isolated from real customer data + - trial readiness can reuse onboarding/readiness and entitlement foundations + - reset/reseed process is safe and documented +- **Risks / open questions**: + - Fake data must not undermine trust by looking like real Microsoft tenant evidence + - Demo mode should not introduce shortcuts into production code paths without explicit safeguards + - Trial provisioning may later become its own larger spec once real acquisition flow is known +- **Dependencies**: StoredReports / EvidenceItems, Findings workflow, Baseline governance, Self-Service Tenant Onboarding, Plans / Entitlements +- **Related specs / candidates**: Customer Review Workspace v1, Tenant Review Run, Product Knowledge & Contextual Help +- **Strategic sequencing**: Medium-high. It becomes more urgent once first external demos and pilots become frequent. +- **Priority**: medium-high + +### Security Trust Pack Light +- **Type**: company-ops / product trust enablement +- **Source**: roadmap update 2026-04-25 — Solo-Founder SaaS Automation & Operating Readiness +- **Problem**: Enterprise buyers will repeatedly ask how TenantPilot handles hosting, data categories, Microsoft permissions, least privilege, RBAC, retention, backups, audit logs, subprocessors, and what is not stored. If these answers remain ad-hoc, sales and onboarding become founder-dependent and inconsistent. +- **Why it matters**: TenantPilot deals with tenant governance artifacts and Microsoft configuration data. Trust documentation is not just legal paperwork; it is a sales and support scalability asset. It also forces the product to stay honest about what it stores, processes, and exposes. +- **Proposed direction**: + - create a lightweight security trust pack aligned to the actual product architecture and data model + - document hosting, data categories, permission model, least-privilege stance, RBAC, audit logging, backup/retention, subprocessors, and non-stored data + - map claims to product features and architecture, avoiding unsupported compliance or certification claims + - keep the pack versioned and updateable as product capabilities change + - identify any product gaps that block truthful trust claims and feed those back into roadmap/spec candidates +- **Scope boundaries**: + - **In scope**: structured trust-pack content, product-data mapping, permission explanation, security overview, gap list, and maintenance ownership + - **Out of scope**: legal finalization, ISO/SOC2 certification, public trust center portal, penetration test execution, or broad security program implementation +- **Acceptance points**: + - trust pack answers the standard first-pass customer security questions consistently + - Microsoft permission explanations match actual provider scopes and product behavior + - data categories and retention claims map to real tables/artifacts or documented operating processes + - unsupported claims are explicitly avoided + - product gaps discovered during trust-pack creation are recorded as roadmap/spec candidates when engineering work is required +- **Risks / open questions**: + - This is partly non-code work; only engineering gaps should become implementation specs + - Legal review may change wording, but not the underlying product truth + - Over-claiming compliance posture would damage trust +- **Dependencies**: Provider permission model, RBAC model, audit logs, retention behavior, backup behavior, deployment/hosting decisions, AVV/DPA/TOM work +- **Related specs / candidates**: Plans / Entitlements & Billing Readiness, System Panel Least-Privilege Capability Model, Provider Boundary Hardening, Evidence domain +- **Strategic sequencing**: Should run before serious enterprise sales conversations and before broad customer onboarding. +- **Priority**: medium-high + +### AI-Assisted Customer Operations +- **Type**: AI-assisted operations / human-in-the-loop product support +- **Source**: roadmap update 2026-04-25 — Mid-term AI-Assisted Customer Operations +- **Problem**: Customer reviews, support triage, finding explanations, diagnostic summaries, release communication, and report summaries can consume large amounts of founder time. However, unbounded AI automation would be risky in a governance product, especially for tenant-changing actions, customer commitments, legal statements, or risk decisions. +- **Why it matters**: TenantPilot can use AI to stay lean, but the product must preserve auditability, human approval, and clear responsibility. The right early AI layer prepares and summarizes work; it does not autonomously change customer tenants or make commitments. +- **Proposed direction**: + - use structured product truth from diagnostic packs, findings, stored reports, evidence, operation runs, and the product knowledge registry as AI input + - generate draft support summaries, finding explanations, tenant review summaries, diagnostic summaries, release-note drafts, and response drafts + - require human approval before customer-facing messages, legal statements, risk acceptance, or tenant-changing actions + - log AI-generated drafts and human approval where product-relevant + - define safety boundaries for what AI can read, suggest, and never execute +- **Scope boundaries**: + - **In scope**: AI draft/summarization workflows for support, findings, reviews, diagnostics, release notes, and customer explanations; approval gates; audit references; source attribution to product records + - **Out of scope**: autonomous tenant remediation, automatic risk acceptance, automatic legal commitments, auto-sending customer communications without review, general-purpose chatbot, or broad AI platform redesign +- **Acceptance points**: + - generated summaries cite or reference underlying product records rather than inventing unsupported conclusions + - customer-facing drafts require human approval before sending or publishing + - tenant-changing actions are not executed by AI in this spec + - AI access is scoped and redacted through existing permission/diagnostic-pack boundaries + - operators can distinguish draft AI text from approved product/customer communication +- **Risks / open questions**: + - AI hallucination risk must be mitigated through structured inputs and source references + - Privacy and data-processing boundaries need explicit review before customer data is sent to any model provider + - The first version should probably be internal-only until diagnostics, knowledge, and support-request foundations are stable +- **Dependencies**: Support Diagnostic Pack, Product Knowledge & Contextual Help, In-App Support Request with Context, StoredReports / EvidenceItems, Findings workflow, release communication process, security/privacy review +- **Related specs / candidates**: Human-in-the-Loop Autonomous Governance, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Security Trust Pack Light +- **Strategic sequencing**: Mid-term. Do not promote before diagnostic, knowledge, and support-context foundations exist. +- **Priority**: medium + +> Recommended sequence for this cluster: +> 1. **Self-Service Tenant Onboarding & Connection Readiness** +> 2. **Support Diagnostic Pack** +> 3. **Product Knowledge & Contextual Help** +> 4. **In-App Support Request with Context** +> 5. **Plans, Entitlements & Billing Readiness** +> 6. **Demo & Trial Readiness** +> 7. **Security Trust Pack Light** +> 8. **AI-Assisted Customer Operations** +> + + +> Additional Solo-Founder Scale Guardrails cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to make the highest-impact solo-founder operating risks measurable, controllable, and product-backed without turning TenantPilot into a CRM, helpdesk, analytics suite, or generic backoffice platform. Pure company-ops artifacts stay in the roadmap; the candidates below are only the product-impacting slices. + +### Product Usage & Adoption Telemetry +- **Type**: product observability / adoption analytics foundation +- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails and Product Usage, Customer Health & Operational Controls +- **Problem**: TenantPilot currently risks relying on founder intuition, support tickets, or manual database/log inspection to understand onboarding drop-off, feature adoption, trial health, failed flows, report/export usage, and support-triggering surfaces. Without privacy-aware product telemetry, it is hard to know where customers get stuck or which product areas actually drive value. +- **Why it matters**: Low-headcount SaaS requires the product to reveal adoption and friction automatically. Telemetry is also a prerequisite for Customer Health Score, lifecycle communication, trial conversion analysis, and prioritizing product work based on behavior rather than anecdotes. +- **Proposed direction**: + - define a minimal product telemetry event contract for product usage and adoption signals + - capture events such as onboarding step completed/blocked, provider connection checked, baseline capture/compare started, report exported, review pack generated, support request opened, contextual help opened, and trial activation milestones + - keep events workspace-/tenant-aware but privacy-aware and avoid raw provider payloads or customer-sensitive data in telemetry + - model event name, actor, workspace, tenant, feature area, subject reference, timestamp, and safe metadata + - provide aggregate read models for adoption dashboards and customer health scoring + - document telemetry boundaries and opt-out / data-processing considerations where appropriate +- **Scope boundaries**: + - **In scope**: internal product telemetry event model, minimal event capture points, privacy/redaction rules, aggregate usage read model, basic operator visibility, and tests for isolation/redaction + - **Out of scope**: full analytics platform, third-party product analytics integration, marketing attribution, session recording, user tracking beyond product-operation needs, or broad BI dashboards +- **Acceptance points**: + - key onboarding, governance, report/export, and support-intake events can be captured through a central contract + - telemetry metadata never stores raw provider payloads or secrets + - workspace/tenant isolation is enforced for telemetry reads + - aggregate adoption indicators can be queried without scanning arbitrary application logs + - telemetry capture can be disabled or bounded by configuration where needed +- **Risks / open questions**: + - Telemetry must not become invasive or create unnecessary privacy exposure + - Too many events too early can create noise; start with high-signal product milestones + - Decide whether telemetry is stored in the primary database initially or written through an adapter seam for future external analytics +- **Dependencies**: Self-Service Tenant Onboarding & Connection Readiness, OperationRun truth, ProviderConnection health, StoredReports / EvidenceItems, Support Diagnostic Pack, audit/data-processing review +- **Related specs / candidates**: Customer Health Score, Customer Lifecycle Communication, Plans / Entitlements & Billing Readiness, Security Trust Pack Light +- **Strategic sequencing**: First item in this guardrails cluster because health score and lifecycle communication need reliable usage signals. +- **Priority**: high + +### Customer Health Score +- **Type**: product observability / customer success signal +- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails and Product Usage, Customer Health & Operational Controls +- **Problem**: Churn, inactive customers, unhealthy provider connections, stale baseline compares, unresolved high-risk findings, overdue SLAs, failed runs, expiring risk acceptances, and missing review packs may be noticed too late if the founder has to manually inspect each workspace. +- **Why it matters**: A solo-founder or low-headcount SaaS needs a simple, trustworthy signal for which customers or workspaces need attention. This is especially important for MSP-oriented governance, where portfolio risk can grow silently across many tenants. +- **Proposed direction**: + - derive workspace/customer health indicators from product truth instead of manual notes + - combine signals such as onboarding status, last login/activity, provider health, last successful sync, baseline compare freshness, open high findings, overdue findings, expiring risk acceptances, failed/stale OperationRuns, support-request volume, review-pack readiness, and trial/billing status where available + - separate health dimensions rather than hiding everything in one opaque score + - provide a simple health summary for founder/operator views and later portfolio surfaces + - keep customer-health calculations explainable and link back to underlying records +- **Scope boundaries**: + - **In scope**: health signal registry, derived health dimensions, explainable health summary, workspace/customer-level health read model, first operator view or dashboard card, and tests + - **Out of scope**: full customer-success CRM, automated churn prediction, external CRM sync, billing collection, sales pipeline scoring, or AI-generated account management actions +- **Acceptance points**: + - a workspace/customer health summary can be generated from product data + - each health warning links to underlying evidence such as provider health, findings, operations, review packs, or trial state + - stale/unknown data is represented explicitly and does not appear healthy by default + - customer health is scoped by workspace and respects authorization boundaries + - at least one dashboard or operator surface can list unhealthy or attention-needed workspaces +- **Risks / open questions**: + - A single numeric score can hide important nuance; dimensions should remain visible + - Missing data must not be treated as good data + - The first version should avoid predictive claims and stay evidence-based +- **Dependencies**: Product Usage & Adoption Telemetry, ProviderConnection health, OperationRun truth, Findings workflow, Risk Acceptance/Exceptions, StoredReports / EvidenceItems, Plans / Entitlements & Billing Readiness +- **Related specs / candidates**: MSP Portfolio Dashboard, Product Usage & Adoption Telemetry, Customer Lifecycle Communication, Support Diagnostic Pack +- **Strategic sequencing**: Second item after telemetry. It should follow reliable signal capture and feed portfolio/customer-success views later. +- **Priority**: high + +### Operational Controls & Feature Flags +- **Type**: operational safety / platform control plane +- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails and Product Usage, Customer Health & Operational Controls +- **Problem**: Incidents or risky product areas may otherwise require code changes, deployments, manual database edits, or ad-hoc communication to pause a feature, block provider-backed actions, disable exports, pause AI functions, stop trials, or place a workspace into a temporary safe state. +- **Why it matters**: Solo-founder operations need safe operator controls. TenantPilot contains high-trust workflows such as restore, provider-backed actions, exports, AI-assisted summaries, and evidence/report generation. These need controlled kill switches and scoped feature flags before scale increases incident pressure. +- **Proposed direction**: + - introduce a minimal operational controls registry with global, workspace, and possibly tenant scope + - support kill switches / flags for risky features such as restore execution, provider-backed writes, exports, AI functions, trial provisioning, report generation, and maintenance/read-only modes + - expose operator-safe controls in the system/platform plane with strong capabilities and audit logging + - define enforcement points through services/gates rather than UI-only hiding + - allow time-bound controls with reason and owner where useful + - provide clear customer/operator messaging when a feature is disabled or paused +- **Scope boundaries**: + - **In scope**: feature flag / operational control model, scoped evaluation service, audited changes, first enforcement points, platform/system UI for controls, and tests + - **Out of scope**: full experimentation platform, A/B testing, remote-config product, external feature flag vendor integration, broad entitlement replacement, or customer-managed feature flags +- **Acceptance points**: + - at least one risky feature can be disabled globally and per workspace through a central control + - enforcement happens server-side at the action/service boundary + - changes are audited with actor, scope, reason, and timestamp + - disabled-state messaging is explicit and not confused with authorization failure + - tests prove UI hiding is not the only enforcement mechanism +- **Risks / open questions**: + - Operational controls must not bypass entitlement/RBAC semantics or become an untracked superpower + - Too many flags can create configuration drift; start with high-risk controls only + - Read-only modes need careful definition so evidence/audit access remains available +- **Dependencies**: System Panel Least-Privilege Capability Model, Provider-Backed Action Preflight and Dispatch Gate Unification, restore/provider action services, export/report services, audit log foundation, Plans / Entitlements & Billing Readiness +- **Related specs / candidates**: Provider-Backed Action Preflight and Dispatch Gate Unification, Plans / Entitlements & Billing Readiness, System Panel Least-Privilege Capability Model, Business Continuity / Founder Backup Plan +- **Strategic sequencing**: High priority once external customers or pilots depend on production. Can be promoted before telemetry if incident-control risk becomes immediate. +- **Priority**: high + +### Customer Lifecycle Communication +- **Type**: customer operations / notification automation +- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails +- **Problem**: Welcome messages, onboarding reminders, trial expiry, provider health warnings, review-pack readiness, risk-expiry reminders, release updates, incidents, renewals, payment issues, and churn-feedback requests can become manual founder communication if they are not structured. +- **Why it matters**: Repeatable SaaS delivery depends on consistent customer communication. Some messages are product-triggered and should be model-backed; others belong to company operations. TenantPilot needs a clear product boundary so important lifecycle events can trigger communication without creating a generic marketing automation system. +- **Proposed direction**: + - define product-triggerable lifecycle communication events for high-value operational moments + - start with onboarding incomplete, provider unhealthy, review pack ready, risk acceptance expiring, trial expiring, incident/update notice, and release note availability where product-backed + - support templates, recipient resolution, locale, delivery channel abstraction, and audit/reference links where appropriate + - distinguish internal operator reminders from customer-facing communication + - keep marketing campaigns and CRM nurture sequences outside the first product slice +- **Scope boundaries**: + - **In scope**: product lifecycle event contract, template registry, recipient resolution, first delivery adapter or outbound hook, audit/reference behavior, and tests for tenant/workspace isolation + - **Out of scope**: full marketing automation, newsletter system, CRM pipeline, payment collection, two-way communication inbox, or generic campaign builder +- **Acceptance points**: + - at least two product-backed lifecycle events can generate structured communication tasks or outbound messages + - recipient selection respects workspace/tenant/customer membership and locale where applicable + - customer-facing messages reference the relevant product object such as tenant, run, finding, review pack, or risk acceptance + - communications are auditable or at least traceable to a product event + - customer-facing communication can be disabled or held for manual approval where needed +- **Risks / open questions**: + - Over-automated customer communication can become noisy or risky during incidents + - Billing/payment messages may depend on external billing systems and should not be over-modeled too early + - Legal/customer-facing statements may need approval rules before automatic sending +- **Dependencies**: Notification Targets / Alerts v1, Product Knowledge & Contextual Help, Plans / Entitlements & Billing Readiness, Customer Health Score, Risk Acceptance/Exceptions, review-pack generation +- **Related specs / candidates**: Alerts v1, AI-Assisted Customer Operations, Product Knowledge & Contextual Help, Release & Customer Communication Automation +- **Strategic sequencing**: Medium-high. Should follow the first telemetry/health foundations and reuse existing alert/notification infrastructure where possible. +- **Priority**: medium-high + +### Product Intake & No-Customization Governance +- **Type**: product operations / roadmap governance +- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails +- **Problem**: Customer-specific requests can silently turn TenantPilot into consulting work if they are implemented as one-off behavior, hidden configuration, or customer-specific branches. Without a product intake and no-customization governance path, each sales/support conversation can create long-term maintenance obligations. +- **Why it matters**: A low-headcount SaaS must protect the product boundary. Feature requests should become product input, not direct custom work by default. This is especially important for MSP and enterprise customers, where individual requests can sound urgent but may not fit the platform direction. +- **Proposed direction**: + - define a lightweight feature/request intake model or documented operating process + - classify requests as no, later, candidate, planned, customer-specific exception, or already covered + - capture customer/segment, problem, workaround, business value, roadmap fit, and maintenance risk + - link accepted requests to spec candidates or promoted specs where appropriate + - require explicit approval and audit/record for any customer-specific exception + - document the no-custom-work policy in product principles or company operating guidance +- **Scope boundaries**: + - **In scope**: product request classification, link to roadmap/spec candidates, exception semantics, optional internal admin surface, and no-customization policy wording + - **Out of scope**: full product management suite, voting portal, public roadmap, customer community, consulting project management, or CRM replacement +- **Acceptance points**: + - customer requests can be classified consistently without becoming immediate implementation tasks + - customer-specific exceptions are explicit, rare, and reviewable + - accepted product requests can link to spec candidates or roadmap themes + - no-custom-work policy is visible in product/company guidance + - the process can be operated manually at first but is structured enough to delegate later +- **Risks / open questions**: + - This may be mostly process at first; only build product surfaces if manual tracking becomes a bottleneck + - Too much process too early could slow learning from pilots + - Exceptions need a business owner and expiry/review path so they do not become permanent hidden product variants +- **Dependencies**: roadmap/spec-candidate process, principles/constitution, customer support/intake process, Plans / Entitlements if exceptions affect limits or features +- **Related specs / candidates**: Plans / Entitlements & Billing Readiness, Customer Lifecycle Communication, Security Trust Pack Light +- **Strategic sequencing**: Medium. Add as a principle/process early; promote to product spec only if in-product request/exception tracking becomes necessary. +- **Priority**: medium + +### Data Retention, Export & Deletion Self-Service +- **Type**: data lifecycle / customer trust / operational scalability +- **Source**: roadmap update 2026-04-25 — Additional Solo-Founder Scale Guardrails +- **Problem**: Customer data export, archive, deletion request handling, trial data expiry, workspace deactivation, and evidence/report retention visibility can become manual support/legal work if the product does not provide clear lifecycle controls and customer-safe visibility. +- **Why it matters**: TenantPilot stores governance artifacts, evidence, reports, findings, and operation history. Customers will ask what is retained, what can be exported, what is deleted, and what remains for audit purposes. Self-service or operator-guided lifecycle flows reduce manual work and improve trust. +- **Proposed direction**: + - define a customer/workspace data lifecycle contract covering active, suspended, archived, trial-expired, deletion-requested, and deleted/retained states where appropriate + - expose retention visibility for reports, evidence, operation runs, findings, exceptions, and backups where already modeled + - provide customer/operator export request flows and deletion/archive request flows with audit events + - make trial data expiry explicit and configurable where tied to plan/entitlement state + - distinguish audit-retained records from deleted customer content and communicate that boundary clearly +- **Scope boundaries**: + - **In scope**: lifecycle state model or request model where needed, export/deletion request flow, retention visibility, audit events, trial expiry handling, and tests for authorization/isolation + - **Out of scope**: full GDPR portal, legal policy drafting, automated physical deletion of every historical artifact without retention analysis, external DSR tooling, or broad storage-engine redesign +- **Acceptance points**: + - customers/operators can see or request export/deletion/archive actions through a defined flow + - retention behavior for key artifact families is visible or documented in-product where appropriate + - trial-expired data handling is explicit and not ad-hoc + - deletion/archive requests are audited and authorized + - audit-retained metadata is clearly separated from customer content deletion semantics +- **Risks / open questions**: + - Legal retention, auditability, and deletion rights must be balanced carefully + - Evidence/report retention may intentionally outlive operation runs; this must be visible and not surprising + - Automation should start conservative until legal review confirms deletion/retention expectations +- **Dependencies**: StoredReports retention, EvidenceItems retention, OperationRun retention, backup retention, Plans / Entitlements & Billing Readiness, Security Trust Pack Light, audit log foundation +- **Related specs / candidates**: StoredReports Model, EvidenceItem Model, Export v1, Security Trust Pack Light, Customer Review Workspace v1 +- **Strategic sequencing**: Medium-high. Should be shaped before broad paid trials and enterprise security reviews, but can land after entitlement and trust-pack foundations. +- **Priority**: medium-high + +> Recommended sequence for this cluster: +> 1. **Product Usage & Adoption Telemetry** +> 2. **Customer Health Score** +> 3. **Operational Controls & Feature Flags** +> 4. **Data Retention, Export & Deletion Self-Service** +> 5. **Customer Lifecycle Communication** +> 6. **Product Intake & No-Customization Governance** +> +> Why this order: first capture reliable signals, then derive health and risk, then add operator control for incidents and risky features, then close customer trust/lifecycle gaps, then automate customer communication, and finally formalize request intake/no-customization once pilot feedback volume increases. + + + + +> Microsoft-first, Provider-extensible Decision-Based Operating cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to move TenantPilot from Microsoft tenant search-and-troubleshoot workflows to decision-based governance operations. Customers and operators should not have to click through tenants, runs, findings, reports, logs, and evidence to discover what needs attention. TenantPilot should detect relevant work, gather context, summarize impact, propose safe next actions, and present decision-ready items. The first product scope stays Microsoft tenant governance; the decision model should avoid hard-coding Microsoft-only assumptions where provider-neutral abstractions already exist. + +### Decision-Based Governance Inbox v1 +- **Type**: product strategy / workflow automation / operator UX +- **Source**: roadmap update 2026-04-25 — Human-in-the-Loop Autonomous Governance (Microsoft-first, Provider-extensible Decision-Based Operating) +- **Problem**: TenantPilot has many rich governance surfaces, but customers and operators can still be forced into search-and-troubleshoot behavior: opening tenants, runs, findings, reports, evidence, provider health, and logs to discover what actually needs a decision. That does not scale for MSPs, customer read-only users, or a low-headcount operating model. +- **Why it matters**: TenantPilot should become the decision control plane for accountable Microsoft tenant governance first, not just a browser for tenant state and execution history. The default workflow should be guided decisions; raw detail pages remain available as evidence and diagnostics. +- **Proposed direction**: + - introduce a Governance Inbox / Action Center that surfaces decision-ready work items across tenants and workspaces + - derive inbox items from findings, drift, exceptions, risk acceptances, provider health, failed/stale OperationRuns, review-pack readiness, evidence gaps, and actionable alerts + - group, deduplicate, and prioritize related signals so operators do not work the same issue multiple times + - show clear decision actions such as review, approve, reject, snooze, assign, accept risk, create ticket, run compare, generate review pack, or request evidence + - link every inbox item to underlying evidence and diagnostic surfaces without making drilldown the primary workflow + - keep the first implementation Microsoft-first while using provider-neutral descriptors where existing platform abstractions support it +- **Scope boundaries**: + - **In scope**: decision inbox item model/read model, source adapters for a small set of high-value signals, grouping/dedup rules, severity/priority handling, action affordances, links to evidence/diagnostics, RBAC/workspace scoping, and first operator UI + - **Out of scope**: autonomous remediation, broad AI agent, full workflow engine, complete MSP portfolio dashboard replacement, customer-facing remediation actions without approval, or support/CRM replacement +- **Acceptance points**: + - operators can see a prioritized list of decision-ready governance items without manually visiting each tenant/run/finding/report first + - each item includes why it matters, affected tenant/workspace, source records, severity/priority, freshness, and available actions + - duplicate/related signals can be grouped or fingerprinted to avoid inbox noise + - actions are server-side authorized and routed through existing OperationRun/workflow/audit patterns where applicable + - detail pages are reachable as evidence, but the main workflow remains decision-first + - tests prove workspace/tenant isolation and prevent unrelated users from seeing inbox items +- **Risks / open questions**: + - Inbox noise is a major risk; grouping and confidence/freshness semantics matter from v1 + - The inbox must not become another dashboard that merely links to raw tables + - The first slice needs carefully selected sources, likely findings, provider health, stale/failed runs, expiring risk acceptances, and review-pack readiness + - Customer-facing visibility may need a later slice with redaction and read-only action limits +- **Dependencies**: Findings workflow, Risk Acceptance/Exceptions, OperationRun truth, ProviderConnection health, StoredReports / EvidenceItems, Alerts v1, Product Usage & Adoption Telemetry, Customer Health Score, Operational Controls & Feature Flags +- **Related specs / candidates**: Decision Pack Contract & Approval Workflow, Findings Operator Inbox v1, Findings Intake & Team Queue v1, Customer Review Workspace v1, MSP Portfolio Dashboard, AI-Assisted Customer Operations +- **Strategic sequencing**: High priority after onboarding/support/telemetry/control foundations because it converts those signals into the primary customer/operator workflow. +- **Priority**: high + +### Decision Pack Contract & Approval Workflow +- **Type**: workflow automation / human-in-the-loop governance contract +- **Source**: roadmap update 2026-04-25 — Human-in-the-Loop Autonomous Governance (Microsoft-first, Provider-extensible Decision-Based Operating) +- **Problem**: A decision inbox is only useful if each item contains enough context to make a safe decision. Without a structured decision pack, operators still have to manually correlate drift, findings, evidence, operations, provider state, risk acceptance, and recommended action before approving or rejecting work. +- **Why it matters**: Human-in-the-loop governance depends on trustworthy, reviewable decision packages: what happened, why it matters, what evidence supports it, what options exist, what the system recommends, what confidence/freshness applies, and what will happen if the operator approves. This is the bridge between detection and controlled execution. +- **Proposed direction**: + - define a Decision Pack contract with summary, impact, affected tenants/policies, source signals, evidence links, confidence/freshness, recommended action, available actions, and expected execution path + - include before/after evidence requirements where an approved action triggers follow-up execution + - require human approval for tenant-changing, customer-facing, or risk-accepting actions + - route approved follow-up through OperationRuns or controlled workflows rather than direct UI-side execution + - audit detection, recommendation, approval/rejection, execution, verification, and evidence attachment + - keep Microsoft-specific details contextual while preserving provider-neutral subject/action vocabulary where possible +- **Scope boundaries**: + - **In scope**: Decision Pack data contract, approval state machine, action registry for first safe actions, audit events, OperationRun handoff, evidence requirements, and tests + - **Out of scope**: autonomous remediation, broad policy engine, multi-approver enterprise workflow, advanced AI recommendation engine, external ticketing deep sync, or automatic legal/customer commitments +- **Acceptance points**: + - a decision pack can be generated for at least one high-value decision source such as critical drift, expiring risk acceptance, failed compare, or review-pack readiness + - the pack shows summary, impact, evidence, source records, recommendation, confidence/freshness, and available actions + - approval/rejection/snooze/assign actions are audited + - tenant-changing or customer-facing actions require explicit approval before execution + - approved execution creates or references an OperationRun or controlled workflow record + - verification and before/after evidence can be attached or requested where applicable +- **Risks / open questions**: + - Too much context can overwhelm operators; the pack must be concise with progressive disclosure + - Recommendations must not overstate certainty; confidence/freshness must be visible + - AI-generated recommendations should remain optional and clearly marked until AI governance boundaries are mature +- **Dependencies**: Decision-Based Governance Inbox v1, Support Diagnostic Pack, Product Knowledge & Contextual Help, OperationRun link contract, Findings workflow, StoredReports / EvidenceItems, Operational Controls & Feature Flags, audit log foundation +- **Related specs / candidates**: AI-Assisted Customer Operations, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Provider-Backed Action Preflight and Dispatch Gate Unification, Customer Lifecycle Communication +- **Strategic sequencing**: Should follow or pair with Governance Inbox v1. The inbox defines the work queue; decision packs make each item decision-ready. +- **Priority**: high + +### Governance Automation Policy Guardrails v1 +- **Type**: automation policy / safety guardrails / future autonomous governance foundation +- **Source**: roadmap update 2026-04-25 — Human-in-the-Loop Autonomous Governance (Microsoft-first, Provider-extensible Decision-Based Operating) +- **Problem**: As TenantPilot moves from detection to guided action, there will be pressure to automate more of the workflow. Without explicit automation policy guardrails, the product risks drifting into unsafe autopilot behavior or, conversely, never automating safe low-risk follow-up. +- **Why it matters**: The product promise is not blind automation. It is accountable governance with human approval where risk matters. Automation policies should define what can be auto-created, auto-assigned, auto-snoozed, auto-notified, or auto-executed, and where approval is mandatory. +- **Proposed direction**: + - define automation policy guardrails for decision item creation, grouping, assignment, notifications, snoozing, ticket creation, review-pack generation, compare runs, and future remediation execution + - classify actions by risk: informational, workflow-only, customer-facing, tenant-changing, risk-accepting, or destructive + - require approval for tenant-changing, customer-facing, risk-accepting, or destructive actions + - support workspace-level policy defaults and optional stricter tenant-level overrides later + - audit policy changes and automation outcomes + - integrate with Operational Controls & Feature Flags so automation can be paused safely +- **Scope boundaries**: + - **In scope**: first automation policy model, action risk taxonomy, approval-required rules, audited policy changes, and enforcement for a small set of workflow-safe actions + - **Out of scope**: full rules engine, customer-authored automation scripting, autonomous remediation, complex multi-step playbooks, or cross-provider policy marketplace +- **Acceptance points**: + - automation policies can distinguish safe workflow automation from approval-required actions + - at least one safe action can run automatically and at least one risky action is blocked until approval + - policy changes are audited with actor, reason, scope, and timestamp + - disabled automation states are clear to operators + - tests prove tenant-changing and risk-accepting actions cannot bypass approval through automation +- **Risks / open questions**: + - Premature policy complexity could slow delivery; start with a small risk taxonomy and a few actions + - Workspace vs tenant policy inheritance must be handled carefully to avoid surprising behavior + - Automation policy should align with future MSP baseline inheritance and customer override semantics +- **Dependencies**: Decision-Based Governance Inbox v1, Decision Pack Contract & Approval Workflow, Operational Controls & Feature Flags, RBAC/capabilities, audit log foundation, Customer Lifecycle Communication +- **Related specs / candidates**: Human-in-the-Loop Autonomous Governance, MSP Portfolio Dashboard, Rollouts v1, Customer Review Workspace v1, AI-Assisted Customer Operations +- **Strategic sequencing**: Medium-high. It should not precede decision inbox and decision pack foundations, but it should land before any autonomous or semi-autonomous remediation features. +- **Priority**: medium-high + +> Recommended sequence for this cluster: +> 1. **Decision-Based Governance Inbox v1** +> 2. **Decision Pack Contract & Approval Workflow** +> 3. **Governance Automation Policy Guardrails v1** +> +> Why this order: first create the decision queue, then make each item decision-ready with evidence and approval semantics, then introduce explicit automation policy guardrails before expanding toward semi-autonomous execution. ### System Panel Least-Privilege Capability Model - **Type**: security hardening / platform-plane RBAC diff --git a/specs/239-canonical-operation-type-source-of-truth/checklists/requirements.md b/specs/239-canonical-operation-type-source-of-truth/checklists/requirements.md new file mode 100644 index 00000000..fd3d25ef --- /dev/null +++ b/specs/239-canonical-operation-type-source-of-truth/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Canonical Operation Type Source of Truth + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-25 +**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 + +- The draft stays bounded to platform-core `operation_type` truth and explicitly avoids broader governed-subject or monitoring-IA cleanup. +- Canonical replacement is preferred, with only a narrowly bounded read-side compatibility seam acknowledged for historical rows and persisted onboarding draft state during rollout. \ No newline at end of file diff --git a/specs/239-canonical-operation-type-source-of-truth/contracts/canonical-operation-type-source-of-truth.logical.openapi.yaml b/specs/239-canonical-operation-type-source-of-truth/contracts/canonical-operation-type-source-of-truth.logical.openapi.yaml new file mode 100644 index 00000000..07e6b0c2 --- /dev/null +++ b/specs/239-canonical-operation-type-source-of-truth/contracts/canonical-operation-type-source-of-truth.logical.openapi.yaml @@ -0,0 +1,276 @@ +openapi: 3.1.0 +info: + title: Canonical Operation Type Source of Truth Logical Contract + version: 0.1.0 + description: | + Logical internal contract for converging operation identity on one canonical + dotted `operation_type` vocabulary. It describes shared shapes for resolving + raw values, normalizing onboarding bootstrap selections, and reading + provider-backed operation definitions under the same platform-owned truth. + Current-release writers must emit canonical dotted values directly. Legacy + aliases are only valid on read-side resolution, filter matching, queued-run + reauthorization, and onboarding draft normalization paths where + `write_allowed` remains false. + It is not a commitment to expose public HTTP routes. +paths: + /logical/operation-types/resolve/{rawValue}: + get: + summary: Resolve a raw operation value to one canonical operation contract + operationId: resolveOperationType + parameters: + - name: rawValue + in: path + required: true + schema: + type: string + responses: + '200': + description: Canonical operation type resolution + content: + application/json: + schema: + $ref: '#/components/schemas/OperationTypeResolution' + /logical/operation-types/filter-options: + post: + summary: Build canonical filter options from observed raw operation values + operationId: buildOperationTypeFilterOptions + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OperationTypeFilterOptionsRequest' + responses: + '200': + description: Canonical filter options and historical query values + content: + application/json: + schema: + $ref: '#/components/schemas/OperationTypeFilterOptionsResponse' + /logical/onboarding/bootstrap-operation-types/normalize: + post: + summary: Normalize onboarding bootstrap selections to canonical operation types + operationId: normalizeOnboardingBootstrapOperationTypes + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OnboardingBootstrapNormalizationRequest' + responses: + '200': + description: Canonical onboarding bootstrap selection result + content: + application/json: + schema: + $ref: '#/components/schemas/OnboardingBootstrapNormalizationResponse' + /logical/provider-operation-definitions/{operationType}: + get: + summary: Read provider-backed operation definition for a canonical operation type + description: | + Provider operation definitions are keyed by canonical dotted operation + codes only. Supplying a legacy alias is outside the write-time/provider + registry contract and must fail before any OperationRun is started. + operationId: getProviderOperationDefinition + parameters: + - name: operationType + in: path + required: true + schema: + type: string + responses: + '200': + description: Provider-backed operation definition and binding status + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderOperationDefinitionResponse' +components: + schemas: + WriteTruthStatus: + type: string + enum: + - canonical_only + - read_side_compatibility_only + - unknown + AliasStatus: + type: string + enum: + - canonical + - legacy_alias + - unknown + CanonicalOperationType: + type: object + properties: + canonical_code: + type: string + display_label: + type: string + domain_key: + type: string + nullable: true + expected_duration_seconds: + type: integer + nullable: true + supports_operator_explanation: + type: boolean + write_truth_status: + $ref: '#/components/schemas/WriteTruthStatus' + required: + - canonical_code + - display_label + - supports_operator_explanation + - write_truth_status + HistoricalOperationTypeAlias: + type: object + properties: + raw_value: + type: string + canonical_code: + type: string + alias_status: + $ref: '#/components/schemas/AliasStatus' + write_allowed: + type: boolean + retirement_boundary: + type: string + enum: + - canonical_current_truth + - read_side_rollout_seam + - unknown + retirement_note: + type: string + nullable: true + required: + - raw_value + - canonical_code + - alias_status + - write_allowed + - retirement_boundary + OperationTypeResolution: + type: object + properties: + raw_value: + type: string + canonical: + $ref: '#/components/schemas/CanonicalOperationType' + alias_status: + $ref: '#/components/schemas/AliasStatus' + was_legacy_alias: + type: boolean + allowed_query_values: + type: array + items: + type: string + aliases_considered: + type: array + items: + $ref: '#/components/schemas/HistoricalOperationTypeAlias' + required: + - raw_value + - canonical + - alias_status + - was_legacy_alias + - allowed_query_values + - aliases_considered + OperationTypeFilterOption: + type: object + properties: + operation_type: + type: string + label: + type: string + raw_query_values: + type: array + items: + type: string + required: + - operation_type + - label + - raw_query_values + OperationTypeFilterOptionsRequest: + type: object + properties: + observed_raw_values: + type: array + items: + type: string + required: + - observed_raw_values + OperationTypeFilterOptionsResponse: + type: object + properties: + options: + type: array + items: + $ref: '#/components/schemas/OperationTypeFilterOption' + required: + - options + OnboardingBootstrapNormalizationRequest: + type: object + properties: + bootstrap_operation_types: + type: array + items: + type: string + required: + - bootstrap_operation_types + OnboardingBootstrapNormalizationResponse: + type: object + properties: + canonical_operation_types: + type: array + items: + type: string + dropped_values: + type: array + items: + type: string + write_truth_status: + $ref: '#/components/schemas/WriteTruthStatus' + required: + - canonical_operation_types + - dropped_values + - write_truth_status + ProviderOperationDefinition: + type: object + properties: + operation_type: + type: string + module: + type: string + label: + type: string + required_capability: + type: string + required: + - operation_type + - module + - label + - required_capability + ProviderOperationBinding: + type: object + properties: + provider: + type: string + binding_status: + type: string + enum: + - active + - unsupported + write_truth_status: + $ref: '#/components/schemas/WriteTruthStatus' + required: + - provider + - binding_status + - write_truth_status + ProviderOperationDefinitionResponse: + type: object + properties: + definition: + $ref: '#/components/schemas/ProviderOperationDefinition' + binding: + $ref: '#/components/schemas/ProviderOperationBinding' + required: + - definition + - binding diff --git a/specs/239-canonical-operation-type-source-of-truth/data-model.md b/specs/239-canonical-operation-type-source-of-truth/data-model.md new file mode 100644 index 00000000..7dac4e06 --- /dev/null +++ b/specs/239-canonical-operation-type-source-of-truth/data-model.md @@ -0,0 +1,190 @@ +# Data Model: Canonical Operation Type Source of Truth + +## Overview + +This feature adds no new table, no new persisted entity, and no new status family. It tightens one existing platform-core contract: the dotted canonical `operation_type` definitions already described by `OperationCatalog` become the only normative write-time and registry-time truth for the touched slice. Historical aliases remain derived and read-side only during rollout. + +## Entity: CanonicalOperationType + +- **Type**: existing derived contract from `App\Support\OperationCatalog` +- **Purpose**: names one operation family consistently across writes, provider bindings, onboarding state, filters, audit metadata, related references, and operator labels. + +### Identity + +- `canonical_code` — stable dotted identifier such as `inventory.sync` or `backup.schedule.execute` + +### Core Fields + +| Field | Type | Notes | +|-------|------|-------| +| `canonical_code` | string | Primary platform contract for touched writes and read models. | +| `display_label` | string | Operator-facing label resolved from `OperationCatalog`. | +| `domain_key` | string nullable | Existing domain grouping metadata; unchanged by this slice. | +| `expected_duration_seconds` | integer nullable | Existing Ops UX timing metadata; unchanged by this slice. | +| `supports_operator_explanation` | boolean | Existing explanation behavior; unchanged by this slice. | +| `alias_retirement_policy` | string | Read-side-only rollout seam for historical aliases. | + +### Validation Rules + +- All new or updated in-scope writes MUST emit `canonical_code` directly. +- Unknown values MUST stay explicitly unknown and MUST NOT inherit a nearby canonical label. +- Canonical dotted codes that already contain underscore segments remain valid current-release truth and MUST NOT be renamed by this feature. + +### Explicit Canonical Codes That Stay Unchanged + +- `backup_set.update` +- `directory.role_definitions.sync` +- `tenant.review_pack.generate` +- `tenant.evidence.snapshot.generate` +- `entra.admin_roles.scan` +- `rbac.health_check` + +## Entity: HistoricalOperationTypeAlias + +- **Type**: existing derived compatibility entry from `OperationCatalog` +- **Purpose**: maps a legacy raw value such as `inventory_sync` or `baseline_capture` to one canonical operation family for historical reads only. + +### Identity + +- `raw_value` — stored or historical identifier encountered in rows, fixtures, or persisted onboarding drafts + +### Core Fields + +| Field | Type | Notes | +|-------|------|-------| +| `raw_value` | string | Historical storage or fixture value. | +| `canonical_code` | string | Canonical dotted operation family. | +| `alias_status` | string | Existing statuses such as `canonical` or `legacy_alias`. | +| `write_allowed` | boolean | `false` for legacy aliases after this feature on touched write paths. | +| `retirement_note` | string nullable | Existing contributor-facing retirement guidance. | +| `match_surfaces` | array | Bounded to `operation_runs.type` historical rows and onboarding draft state for this slice. | + +### Validation Rules + +- Legacy aliases MAY be resolved only on read paths. +- New or updated write-time callers MUST NOT emit legacy aliases. +- Alias support MUST remain removable and MUST NOT require dual-write behavior. + +## Entity: OperationRun + +- **Type**: existing persisted model +- **Purpose in this feature**: remains the canonical operational record while its `type` field is hardened toward canonical dotted values for new writes and still resolves historical aliases on read. + +### Relevant Fields + +| Field | Type | Notes | +|-------|------|-------| +| `type` | string | Existing persisted field; new in-scope writes must use canonical dotted codes. Historical rows may still contain legacy aliases during rollout. | +| `run_identity_hash` | string | Dedupe identity; unchanged. | +| `context` | json/array | Existing metadata surface; touched summaries and audit-adjacent payloads should emit canonical `operation_type`. | +| `summary_counts` | json/array | Existing Ops-UX counters; unchanged. | +| `status` | string | Lifecycle remains service-owned and unchanged. | +| `outcome` | string | Lifecycle remains service-owned and unchanged. | + +### Relationships + +| Relationship | Target | Purpose | +|--------------|--------|---------| +| `workspace` | `Workspace` | Keeps workspace isolation explicit. | +| `tenant` | `Tenant` | Keeps tenant scope explicit for filters, triage, and onboarding. | +| `resolvedOperationType()` | `OperationTypeResolution` | Existing read-path resolution that must remain the only compatibility seam for legacy aliases. | + +### Feature-Specific Invariants + +- `resolvedOperationType()` and `canonicalOperationType()` remain the read-path truth for historical rows. +- Touched write owners must stop relying on `OperationRunType::canonicalCode()` as a second-step translation. +- Type-specific branches that currently compare raw aliases should compare canonical truth or canonical literals after the write owners converge. + +## Entity: OnboardingBootstrapSelection + +- **Type**: existing persisted workflow state inside `managed_tenant_onboarding_sessions.state` +- **Purpose in this feature**: holds selected bootstrap operation types and started bootstrap run references for the onboarding wizard. + +### Relevant Fields + +| Field | Type | Notes | +|-------|------|-------| +| `bootstrap_operation_types[]` | array | Load path may encounter legacy aliases; save and start paths must persist canonical dotted codes only after this feature. | +| `bootstrap_operation_runs` | map | Keys should follow the same canonical operation codes used by `bootstrap_operation_types`. | +| `started_operation_type` | string nullable | Audit-adjacent summary field that should emit canonical dotted code for new writes. | + +### Validation Rules + +- Resume behavior may normalize historical aliases. +- Persisted selections after any touched save or start action MUST be canonical dotted codes only. +- Unknown bootstrap values MUST be dropped or remain explicitly unsupported; they MUST NOT map to a nearby canonical action silently. + +## Entity: ProviderOperationDefinition + +- **Type**: existing shared provider registry definition +- **Purpose in this feature**: declares provider-backed operation metadata while consuming canonical platform-owned `operation_type` values. + +### Relevant Fields + +| Field | Type | Notes | +|-------|------|-------| +| `operation_type` | string | Must be canonical dotted code after this feature. | +| `module` | string | Existing provider module grouping; unchanged. | +| `label` | string | Existing operator-facing label; unchanged. | +| `required_capability` | string | Existing capability binding; unchanged. | +| `provider_binding.provider` | string | Provider-owned runtime binding; unchanged in concept. | + +### Validation Rules + +- Registry definitions and provider bindings MUST reference the same canonical dotted `operation_type`. +- Provider binding MUST remain provider-owned, while `operation_type` remains platform-core. +- Unsupported combinations still block explicitly; this feature does not weaken start-gate safety. + +## Entity: OperationTypeMetadataPayload + +- **Type**: existing derived metadata shape across audit, triage, alert, and reference contexts +- **Purpose in this feature**: ensures operator-adjacent payloads stop copying raw `run->type` as current-release truth. + +### Relevant Fields + +| Field | Type | Notes | +|-------|------|-------| +| `operation_type` | string | Should emit canonical dotted code in touched metadata payloads. | +| `operation_run_id` | integer | Existing reference to the underlying run. | +| `summary_counts` | array | Existing flat metrics; unchanged. | +| `scope` / `target_scope` | array or string | Existing scope context; unchanged. | + +### Validation Rules + +- Operator-adjacent metadata MUST not reintroduce legacy raw aliases as first-class truth. +- Storage-oriented raw `type` MAY remain in the row itself during rollout, but touched metadata should emit canonical `operation_type`. + +## Relationships + +- One `CanonicalOperationType` may have many `HistoricalOperationTypeAlias` entries. +- One `OperationRun` resolves to exactly one `CanonicalOperationType` on read via `OperationCatalog`. +- One `OnboardingBootstrapSelection` stores many canonical operation codes and may map each selected canonical code to one run ID. +- One `ProviderOperationDefinition` references exactly one `CanonicalOperationType` and may have one active provider binding in the current release. +- One `OperationTypeMetadataPayload` should mirror the canonical operation identity for the underlying `OperationRun` without becoming a second source of truth. + +## Rollout / Lifecycle Rules + +### Write-time truth + +- New or updated in-scope writes use canonical dotted `operation_type` values directly. +- Legacy aliases are not permitted as new current-release truth on touched writers, registries, config keys, or onboarding persistence. + +### Read-time compatibility + +- Historical `operation_runs.type` values and historical onboarding draft selections may still resolve through the alias map during rollout. +- Filter query expansion may continue to use `rawValuesForCanonical()` only where historical rows must still be matched. + +### Unknown handling + +- Unknown values remain explicitly unknown and never auto-normalize to a nearby canonical family. + +### Seam retirement + +- Once historical rows and fixtures are no longer needed, alias entries and onboarding normalization fallbacks can be removed without changing the canonical contract. + +## Persistence Impact + +- **Schema changes**: None +- **New tables**: None +- **Backfill jobs or migrations**: None planned in this slice +- **Config updates**: Existing keys update in place to canonical dotted values where touched \ No newline at end of file diff --git a/specs/239-canonical-operation-type-source-of-truth/plan.md b/specs/239-canonical-operation-type-source-of-truth/plan.md new file mode 100644 index 00000000..a34e0c8e --- /dev/null +++ b/specs/239-canonical-operation-type-source-of-truth/plan.md @@ -0,0 +1,289 @@ +# Implementation Plan: Canonical Operation Type Source of Truth + +**Branch**: `239-canonical-operation-type-source-of-truth` | **Date**: 2026-04-25 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/239-canonical-operation-type-source-of-truth/spec.md` + +**Note**: This plan keeps the slice intentionally tight around one platform-core contract: dotted canonical `operation_type` codes become the only normative truth for touched writes and shared read models. No application code is implemented here. + +## Summary + +Promote the existing dotted `OperationCatalog` codes to the single normative `operation_type` contract, converge current write owners such as `OperationRunType`, provider operation definitions, onboarding bootstrap persistence, and lifecycle policy config to emit canonical dotted values directly, and keep only one bounded read-side compatibility seam for historical `operation_runs.type` rows and persisted onboarding draft state. The implementation stays inside the existing catalog, provider-start, onboarding, monitoring, and audit seams, avoids new tables or new abstraction layers, and uses focused unit, feature, Livewire, and architecture coverage to block raw alias drift from returning. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 +**Primary Dependencies**: existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4 +**Storage**: PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables +**Testing**: Pest unit, feature, Filament Livewire, and existing heavy-governance tests run through Laravel Sail +**Validation Lanes**: `fast-feedback`, `confidence`, `heavy-governance` +**Target Platform**: Laravel monolith in `apps/platform` with native Filament v5 admin and system surfaces plus queue-backed `OperationRun` workflows +**Project Type**: Monorepo with one Laravel runtime in `apps/platform`; `apps/website` is out of scope +**Performance Goals**: keep canonical resolution deterministic and in-process, preserve DB-only monitoring render paths, preserve existing filter/query shape efficiency, and avoid new query fan-out or render-time remote work +**Constraints**: no new table, no new abstraction framework, no new operation family, no monitoring IA redesign, no broader governed-subject cleanup outside `operation_type`, no dual-write compatibility path, no change to authorization semantics, and no provider-neutral rename sweep +**Scale/Scope**: one existing canonical catalog, one enum-backed contract hotspot, one provider registry/start gate seam, one onboarding bootstrap state seam, one lifecycle config seam, several read-model/presenter consumers, and focused guard coverage + +## Filament v5 Implementation Contract + +- **Livewire v4.0+ compliance**: Preserved. Touched admin and system surfaces remain native Filament v5 / Livewire v4 surfaces and no legacy Livewire or Filament APIs are introduced. +- **Provider registration location**: Unchanged. Panel providers remain registered in `apps/platform/bootstrap/providers.php`. +- **Global search impact**: No new globally searchable surface is introduced. `OperationRunResource` currently registers no pages (`getPages(): []`), so this slice keeps operation-type hardening outside global search rather than adding View/Edit pages just for searchability. +- **Destructive actions**: No new destructive action is planned. Existing destructive actions touched indirectly remain server-authorized and keep `Action::make(...)->action(...)->requiresConfirmation()` where already required; onboarding completion confirmation stays unchanged. +- **Asset strategy**: No new assets or panel registrations are planned. Deployment expectations remain unchanged; if later UI work adds registered assets, deploy still runs `cd apps/platform && php artisan filament:assets`. +- **Testing plan**: Prove canonical contract convergence with focused unit coverage for resolution or write-truth, focused feature and Livewire coverage for operations filters, onboarding resume/start behavior, tenant-safe detail navigation, and DB-only rendering, plus existing heavy-governance coverage for platform vocabulary and raw-alias or non-leakage drift. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament surfaces plus existing shared presenters/helpers +- **Shared-family relevance**: operations list/detail/filter family, onboarding bootstrap launch family, related-navigation/reference family, audit-adjacent summaries +- **State layers in scope**: page, detail, URL-query +- **Handling modes by drift class or surface**: raw write-time aliases are `hard-stop-candidate`; read-side compatibility is `review-mandatory` +- **Repository-signal treatment**: `future hard-stop candidate` +- **Special surface test profiles**: `monitoring-state-page`, `standard-native-filament`, `shared-detail-family` +- **Required tests or manual smoke**: `functional-core`, `state-contract` +- **Exception path and spread control**: one named read-side compatibility boundary limited to historical `operation_runs.type` rows and persisted onboarding draft state; no write-side exception +- **Active feature PR close-out entry**: `Guardrail` + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `OperationCatalog`, `OperationRunType`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `FilterOptionCatalog`, `OperationRunResource`, `ManagedTenantOnboardingWizard`, `OperationRunLinks`, `OperationUxPresenter`, `OperationRunReferenceResolver`, `AuditEventBuilder`, `OperationRunService`, `OperationRunTriageService`, `FindingsLifecycleBackfillRunbookService`, and `tenantpilot` vocabulary/lifecycle config +- **Shared abstractions reused**: `OperationCatalog` as the sole canonical contract, `OperationRunService` for run identity and lifecycle, `ProviderOperationStartGate` for shared start UX, `FilterOptionCatalog` for canonical filter options, and existing presenter/reference builders for operator labels +- **New abstraction introduced? why?**: none planned. If a tiny helper is needed, it must replace duplicated raw comparisons inside existing seams rather than create a new registry or translator family. +- **Why the existing abstraction was sufficient or insufficient**: `OperationCatalog` is already the correct label and canonical-code source, but it is insufficient while enum-backed writes, provider registry definitions, onboarding selections, lifecycle config, and raw type-specific branches still treat aliases as peer truths. +- **Bounded deviation / spread control**: compatibility stays read-side only inside the existing catalog and onboarding-normalization path; no dual-write, fallback writer, or long-lived alias preservation layer is allowed. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes +- **Central contract reused**: shared OperationRun UX layer via `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, `OperationRunService`, and `OperationUxPresenter` +- **Delegated UX behaviors**: queued toast, run link, run-enqueued browser event, dedupe-or-scope-busy messaging, queued DB-notification decision, tenant/workspace-safe URL resolution +- **Surface-owned behavior kept local**: onboarding keeps only bootstrap selection and provider-connection inputs; operations surfaces remain read-only label/filter consumers +- **Queued DB-notification policy**: explicit opt-in, unchanged +- **Terminal notification path**: central lifecycle mechanism, unchanged +- **Exception path**: none. The only bounded exception is read-side alias compatibility, not a local UX contract bypass. + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes +- **Provider-owned seams**: current provider bindings and provider-specific operation families behind `ProviderOperationRegistry` / `ProviderOperationStartGate`, plus provider-specific canonical families such as `directory.groups.sync` and `entra.admin_roles.scan` +- **Platform-core seams**: `operation_type` vocabulary, `OperationCatalog`, filter-option convergence, monitoring/read-model summaries, onboarding persisted selection truth, audit metadata, and lifecycle policy config +- **Neutral platform terms / contracts preserved**: `operation`, `operation_type`, `canonical operation code`, `operation catalog`, `operation label` +- **Retained provider-specific semantics and why**: current dotted provider-owned codes remain valid canonical codes when the operation itself is provider-specific; the slice tightens truth ownership without renaming all provider-domain vocabulary +- **Bounded extraction or follow-up path**: `follow-up-spec` for broader governed-subject or provider/domain vocabulary cleanup once `operation_type` truth is stable + +## Constitution Check + +*GATE: Passed before Phase 0 research. Re-check after Phase 1 design: still passes with one bounded read-side compatibility seam and no new persistence or framework.* + +| Gate | Status | Plan Notes | +|------|--------|------------| +| Inventory-first / read-write separation | PASS | The feature hardens an existing operational contract. No new mutable workflow beyond canonical identifier replacement is introduced. | +| Workspace + tenant isolation / RBAC-UX | PASS | No new route, plane, or capability family is added. Existing `/admin`, tenant-context, and `/system` semantics remain unchanged while filter/read models stay entitlement-safe. | +| Run observability / Ops-UX lifecycle | PASS | Existing `OperationRun` creation, dedupe, `summary_counts`, and notification behavior remain service-owned. Only `operation_type` identity entering those paths changes. | +| Shared pattern first (XCUT-001) | PASS | The plan reuses `OperationCatalog`, `OperationRunService`, `ProviderOperationStartGate`, and existing presenter/reference helpers rather than introducing a new translation framework. | +| Provider boundary (PROV-001) | PASS | Platform-core `operation_type` truth is tightened while provider-specific operation families remain bounded at provider-owned seams. | +| Proportionality / no premature abstraction | PASS | The implementation converges on existing catalog/config/service seams and rejects a new registry/resolver layer. | +| Persisted truth / behavioral state | PASS | No new table, entity, or status family is added. Existing rows and onboarding drafts remain readable only through the bounded rollout seam. | +| LEAN-001 compatibility bias | PASS with explicit spec exception | Pre-production replacement remains the default. The spec explicitly allows only a narrow read-side seam for historical rows and persisted draft state; no write-side preservation is allowed. | +| Filament v5 + Livewire v4 contract | PASS | Touched surfaces stay native Filament v5/Livewire v4, panel provider registration remains unchanged, and no new global-search surface is introduced. | +| Destructive action safety | PASS | No new destructive action is added. Existing confirmation and authorization requirements remain unchanged. | +| Asset strategy | PASS | No asset change is planned. | +| Test governance | PASS | Coverage stays in focused unit and feature lanes plus existing heavy-governance families; no browser lane or new heavy-governance family is required for proof. | + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Unit` for canonical resolution and write-truth enforcement, `Feature` for onboarding and operations filter or detail behavior, `Feature/Livewire` for Filament filter/state flows, `Heavy-Governance` for platform vocabulary and anti-drift or non-leakage guards +- **Affected validation lanes**: `fast-feedback`, `confidence`, `heavy-governance` +- **Why this lane mix is the narrowest sufficient proof**: the risk is semantic drift at shared contract boundaries, operations-surface entitlement leakage, and accidental render-path regression, not browser rendering. Unit tests prove canonical identity rules and registry writes; focused feature/Livewire tests prove the main operator surfaces, tenant-safe detail navigation, and DB-only rendering; existing heavy-governance guards catch reintroduced raw alias writers and workspace or tenant leakage. +- **Narrowest proving commands**: + - `Current repo baseline before implementation: export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationTypeResolutionTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Onboarding/OnboardingDraftResolverTest.php` + - `Post-implementation expanded unit proof after the planned canonical-contract tests land: export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationTypeResolutionTest.php tests/Unit/Support/OperationRunTypeCanonicalContractTest.php tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Onboarding/OnboardingDraftResolverTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunListFiltersTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Monitoring/AuditCoverageOperationsTest.php tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Architecture/PlatformVocabularyBoundaryGuardTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- **Fixture / helper / factory / seed / context cost risks**: moderate. Existing factories and helpers default many runs and onboarding drafts to legacy alias strings such as `inventory_sync` or `backup_schedule_run`; the slice must update those defaults deliberately instead of adding a second helper layer. +- **Expensive defaults or shared helper growth introduced?**: no; fixture changes should reduce alias drift rather than add compatibility helpers +- **Heavy-family additions, promotions, or visibility changes**: existing heavy-governance families in `tests/Architecture` and `tests/Feature/OpsUx` are touched, but no new heavy family is introduced and no browser scope is added +- **Surface-class relief / special coverage rule**: `monitoring-state-page` and `standard-native-filament` relief are sufficient; no browser proof is required +- **Closing validation and reviewer handoff**: reviewers should confirm that touched writes emit canonical dotted codes, filter options collapse alias duplicates, onboarding resume canonicalizes historical selections, raw-type branches are replaced or deliberately bounded, operations tenant-scope and tenantless detail behavior remain entitlement-safe, DB-only rendering stays DB-only, and compatibility remains read-side only +- **Budget / baseline / trend follow-up**: none expected beyond localized fixture updates +- **Review-stop questions**: did any touched writer still emit raw aliases? Did any new compatibility path write or preserve aliases? Did the slice widen into broader vocabulary cleanup? Did fixture defaults or guard tests start treating historical aliases as acceptable new truth? Did canonicalization weaken tenant-scope or tenantless-viewer entitlement checks? Did any touched operations surface stop rendering from DB-only state? +- **Escalation path**: `document-in-feature` +- **Active feature PR close-out entry**: `Guardrail` +- **Why no dedicated follow-up spec is needed**: the remaining broader vocabulary cleanup is already out of scope and explicitly deferred; this slice can be proven inside existing lanes. + +## Project Structure + +### Documentation (this feature) + +```text +specs/239-canonical-operation-type-source-of-truth/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── canonical-operation-type-source-of-truth.logical.openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root; target touched surfaces after implementation) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/ +│ │ │ └── Workspaces/ +│ │ │ └── ManagedTenantOnboardingWizard.php +│ │ └── Resources/ +│ │ └── OperationRunResource.php +│ ├── Models/ +│ │ └── OperationRun.php +│ ├── Services/ +│ │ ├── Audit/ +│ │ │ └── AuditEventBuilder.php +│ │ ├── Providers/ +│ │ │ ├── ProviderOperationRegistry.php +│ │ │ └── ProviderOperationStartGate.php +│ │ ├── Runbooks/ +│ │ │ └── FindingsLifecycleBackfillRunbookService.php +│ │ ├── SystemConsole/ +│ │ │ └── OperationRunTriageService.php +│ │ └── OperationRunService.php +│ └── Support/ +│ ├── Filament/ +│ │ └── FilterOptionCatalog.php +│ ├── OpsUx/ +│ │ └── OperationUxPresenter.php +│ ├── References/Resolvers/ +│ │ └── OperationRunReferenceResolver.php +│ ├── OperationCatalog.php +│ ├── OperationRunLinks.php +│ └── OperationRunType.php +├── config/ +│ └── tenantpilot.php +└── tests/ + ├── Architecture/ + │ └── PlatformVocabularyBoundaryGuardTest.php + ├── Feature/ + │ ├── Filament/ + │ │ ├── OperationRunListFiltersTest.php + │ │ └── RecentOperationsSummaryWidgetTest.php + │ ├── Guards/ + │ │ └── OperationRunLinkContractGuardTest.php + │ ├── ManagedTenantOnboardingWizardTest.php + │ └── Workspaces/ + │ └── ManagedTenantOnboardingProviderStartTest.php + └── Unit/ + ├── Providers/ + │ ├── ProviderOperationRegistryCanonicalTypeTest.php + │ └── ProviderOperationStartGateTest.php + └── Support/ + ├── OperationRunTypeCanonicalContractTest.php + └── OperationTypeResolutionTest.php +``` + + Additional concrete seams intentionally included by the task plan inside this same boundary are `apps/platform/app/Support/Operations/OperationLifecyclePolicy.php`, `apps/platform/app/Services/Evidence/Sources/OperationsSummarySource.php`, `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php`, `apps/platform/app/Http/Controllers/AdminConsentCallbackController.php`, `apps/platform/app/Services/Inventory/InventorySyncService.php`, `apps/platform/app/Services/BackupScheduling/BackupScheduleDispatcher.php`, `apps/platform/app/Services/ReviewPackService.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewService.php`. + +**Structure Decision**: keep the slice entirely inside the existing Laravel runtime in `apps/platform`, extending current operation-support, provider-start, onboarding, and monitoring seams. No new top-level codebase area or secondary framework is needed. + +## Complexity Tracking + +No constitutional violation is planned. One bounded complexity concern is tracked because the feature explicitly preserves a temporary compatibility seam despite LEAN-001’s default replacement bias. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| Bounded read-side compatibility seam for historical `operation_runs.type` rows and onboarding draft arrays | Existing rows and persisted drafts already hold aliases that the spec requires to remain readable during rollout, but only on read paths | Mass rewriting historical rows and draft payloads inside this slice would broaden scope into data migration/cleanup; write-side preservation or dual-write is forbidden | + +## Proportionality Review + +- **Current operator problem**: the same operation family is emitted, stored, filtered, audited, and reviewed under different identifiers, which weakens operator trust and lets new drift re-enter shared seams. +- **Existing structure is insufficient because**: `OperationCatalog` already knows the canonical dotted contract, but `OperationRunType`, provider registry definitions, onboarding selections, lifecycle config, and raw branch logic still treat aliases as first-class truth. +- **Narrowest correct implementation**: make the existing catalog the only normative contract, change touched writers to emit canonical dotted codes directly, and limit compatibility to read-time alias resolution for historical rows and onboarding draft state. +- **Ownership cost created**: several focused code paths and tests must be updated together, and the bounded alias map needs explicit retirement review instead of silently becoming permanent. +- **Alternative intentionally rejected**: keeping long-lived dual semantics via `canonicalCode()` or adding a broader operation-type resolver framework. Both preserve drift and add maintenance cost without solving the underlying truth split. +- **Release truth**: current-release anti-drift hardening for a platform-core canonical noun + +## Phase 0 Research Summary + +- `OperationCatalog` is already the correct canonical authority; `OperationRunType::canonicalCode()` is now evidence of drift, not the desired steady-state API. +- The first-slice write owners are `OperationRunType`, `ProviderOperationRegistry` / `ProviderOperationStartGate`, onboarding bootstrap selection persistence/start, and `tenantpilot.operations.lifecycle.covered_types`. +- Raw type branches still exist in `OperationRunResource`, `OperationRunLinks`, and non-UI metadata emitters such as `OperationRunService`, `OperationRunTriageService`, and `FindingsLifecycleBackfillRunbookService`. +- Canonical dotted codes that legitimately retain underscore segments and must not trigger broader cleanup include `backup_set.update`, `directory.role_definitions.sync`, `tenant.review_pack.generate`, `tenant.evidence.snapshot.generate`, `entra.admin_roles.scan`, and `rbac.health_check`. +- The only allowed compatibility seam is read-side alias resolution for historical rows and persisted onboarding drafts. +- Focused unit, feature, Livewire, and existing heavy-governance coverage is sufficient; browser proof is not required for this slice. + +## Phase 1 Design Summary + +- `research.md` records contract decisions, the explicit underscore-segment exceptions that remain canonical, and the non-UI metadata sites that still surface raw `type`. +- `data-model.md` defines the canonical operation-type contract, legacy alias seam, onboarding bootstrap selection truth, and provider operation definition requirements. +- `contracts/canonical-operation-type-source-of-truth.logical.openapi.yaml` records the logical contract for resolving raw values, normalizing onboarding selections, and reading canonical filter/write metadata. +- `quickstart.md` captures the narrow implementation order and review-proof commands. +- `tasks.md` remains Phase 2 work and is not created by `/speckit.plan`. + +## Phase 1 — Agent Context Update + +Run after artifact generation: + +- `.specify/scripts/bash/update-agent-context.sh copilot` + +## Implementation Strategy + +### Phase A — Collapse write-time truth onto canonical dotted codes + +**Goal**: remove peer write truths before touching broader read surfaces. + +- Update `app/Support/OperationRunType.php` so enum-backed producers stop relying on `canonicalCode()` translation and instead represent canonical dotted values directly or become a pure compatibility shim slated for removal. +- Align `app/Services/Providers/ProviderOperationRegistry.php`, `ProviderOperationStartGate.php`, and onboarding/bootstrap capability resolution in `ManagedTenantOnboardingWizard.php` to use canonical dotted `operation_type` values as their emitted and persisted contract. +- Convert `apps/platform/config/tenantpilot.php` operation lifecycle `covered_types` keys to canonical dotted codes and keep any historical storage compatibility bounded to read/lookup paths. +- Sweep the first-slice service/job start owners that currently use `OperationRunType::...->value` or raw aliases and make their emitted run type canonical at the source. + +### Phase B — Bound compatibility to read-time resolution only + +**Goal**: keep historical rows and draft state readable without preserving legacy write behavior. + +- Keep alias resolution centralized in `OperationCatalog` and trim the alias inventory to explicitly read-side cases only. +- Normalize historical onboarding draft `bootstrap_operation_types` on load, save, and start so resumed drafts stop writing aliases back into session state. +- Ensure unknown or unsupported operation values remain explicitly unknown and never inherit nearby canonical labels. +- Do not add backfill migrations, dual-write logic, or fallback writers. + +### Phase C — Converge read models, filters, and operator-adjacent metadata + +**Goal**: make all touched consumers read through one canonical contract. + +- Replace raw type comparisons in `OperationRunResource`, `OperationRunLinks`, and other touched type-specific branches with canonical checks or canonical value literals. +- Keep `FilterOptionCatalog`, `OperationUxPresenter`, `OperationRunReferenceResolver`, and `AuditEventBuilder` on `OperationCatalog` resolution, and update any remaining raw-type assumptions they rely on. +- Canonicalize operator-adjacent metadata payloads in `OperationRunService`, `OperationRunTriageService`, `FindingsLifecycleBackfillRunbookService`, and onboarding audit metadata where `operation_type` is currently copied from raw storage. +- Preserve query efficiency by using `OperationCatalog::rawValuesForCanonical()` only where historical storage rows still need to be matched. + +### Phase D — Lock the boundary with focused tests and guards + +**Goal**: make raw alias reintroduction fail fast. + +- Extend unit coverage for canonical resolution and add guard coverage for enum/registry canonical contract behavior. +- Extend operations filter and onboarding start/resume feature coverage so one canonical selection maps to both current and historical rows without leaking cross-tenant data. +- Tighten heavy-governance guard tests so new in-scope alias writers, tenant-leakage paths, or registry entries fail review and CI. +- Update shared fixture defaults only where necessary to stop teaching aliases as normal current-release truth. + +## Risks and Mitigations + +- **Scope creep into broader vocabulary cleanup**: mitigate by explicitly preserving already-canonical underscore-segment codes and keeping all non-`operation_type` naming outside this spec. +- **Compatibility seam persistence**: mitigate by restricting alias handling to catalog and onboarding read normalization and rejecting any write-side fallback. +- **Partial convergence**: mitigate by sequencing write owners before read-model cleanup and using guard coverage for registry/start paths. +- **Fixture churn**: mitigate by updating only the affected factories/helpers and avoiding a second compatibility helper layer. + +## Post-Design Re-check + +The feature remains constitution-compliant and implementation-ready. It introduces no new table, no new abstraction framework, no new operation family, no monitoring IA redesign, no provider-platform boundary rewrite beyond the in-scope contract, and no Filament panel or asset change. The plan keeps compatibility explicitly bounded and read-side only, preserves Livewire v4 / Filament v5 conventions, keeps provider registration in `bootstrap/providers.php`, leaves global search unchanged, and centers proof on focused unit, feature, Livewire, and existing heavy-governance tests. + +## Implementation Close-Out + +- **Guardrail disposition**: `Guardrail` remains the active close-out entry. The implementation stayed inside `operation_type` truth and did not add a generic compatibility framework, new persistence, panel changes, assets, or global-search changes. +- **Compatibility disposition**: `document-in-feature`. Historical alias handling remains bounded to read-side resolution/filter matching, queued-run reauthorization, and onboarding draft normalization. No writer may use a legacy alias as registry or persisted current-release truth. +- **Deferred boundary**: `follow-up-spec` remains appropriate only for broader provider/domain vocabulary cleanup outside this slice, such as future governed-subject naming work. +- **Validation recorded**: focused US1, US2, US3, and affected writer/queue regression lanes ran through Sail/Pest before final validation. Final handoff requires the full quickstart command in `quickstart.md` plus dirty-file Pint. diff --git a/specs/239-canonical-operation-type-source-of-truth/quickstart.md b/specs/239-canonical-operation-type-source-of-truth/quickstart.md new file mode 100644 index 00000000..0d89dfb2 --- /dev/null +++ b/specs/239-canonical-operation-type-source-of-truth/quickstart.md @@ -0,0 +1,127 @@ +# Quickstart: Canonical Operation Type Source of Truth + +## Goal + +Make dotted canonical `operation_type` codes the single normative platform contract for the touched slice, while keeping only a bounded read-side compatibility seam for historical `operation_runs.type` rows and persisted onboarding draft state. + +## Prerequisites + +1. Start the local stack: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d +``` + +2. Work on branch `239-canonical-operation-type-source-of-truth`. +3. Keep the slice tightly bounded to `operation_type` truth only. Do not widen into broader governed-subject cleanup, monitoring IA changes, or historical data backfill. + +## Implementation Sequence + +1. Normalize the core contract first: + - `app/Support/OperationCatalog.php` + - `app/Support/OperationRunType.php` + - `config/tenantpilot.php` + Preserve already-canonical dotted codes that still contain underscore segments, including `backup_set.update`, `directory.role_definitions.sync`, `tenant.review_pack.generate`, `tenant.evidence.snapshot.generate`, `entra.admin_roles.scan`, and `rbac.health_check`. +2. Converge the first-slice write owners: + - `app/Services/Providers/ProviderOperationRegistry.php` + - `app/Services/Providers/ProviderOperationStartGate.php` + - `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` + - any touched service or job start sites still emitting `OperationRunType::...->value` or raw aliases +3. Bound compatibility to read time only: + - keep alias resolution centralized in `OperationCatalog` + - normalize onboarding draft `bootstrap_operation_types` on load, save, and start + - do not add dual-write, fallback writers, or data backfill +4. Converge read models, links, and audit-adjacent metadata: + - `app/Filament/Resources/OperationRunResource.php` + - `app/Support/Filament/FilterOptionCatalog.php` + - `app/Support/OpsUx/OperationUxPresenter.php` + - `app/Support/References/Resolvers/OperationRunReferenceResolver.php` + - `app/Support/OperationRunLinks.php` + - `app/Services/Audit/AuditEventBuilder.php` + - `app/Services/OperationRunService.php` + - `app/Services/SystemConsole/OperationRunTriageService.php` + - `app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php` +5. Tighten tests and fixture defaults so new current-release writes stop teaching aliases as normal truth. + +Additional concrete seams explicitly called out by the task plan within this same slice: + - `app/Support/Operations/OperationLifecyclePolicy.php` + - `app/Services/Evidence/Sources/OperationsSummarySource.php` + - `app/Services/Onboarding/OnboardingLifecycleService.php` + - `app/Http/Controllers/AdminConsentCallbackController.php` + - `app/Services/Inventory/InventorySyncService.php` + - `app/Services/BackupScheduling/BackupScheduleDispatcher.php` + - `app/Services/ReviewPackService.php` + - `app/Services/Evidence/EvidenceSnapshotService.php` + - `app/Services/TenantReviews/TenantReviewService.php` + +## Tests To Update Or Add + +1. Canonical resolution and write-truth unit coverage: + - `tests/Unit/Support/OperationTypeResolutionTest.php` + - `tests/Unit/Support/OperationRunTypeCanonicalContractTest.php` + - `tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php` + - `tests/Unit/Providers/ProviderOperationStartGateTest.php` +2. Operations filters and onboarding feature coverage: + - `tests/Feature/Filament/OperationRunListFiltersTest.php` + - `tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php` + - `tests/Feature/Monitoring/AuditCoverageOperationsTest.php` + - `tests/Feature/Monitoring/OperationsTenantScopeTest.php` + - `tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php` + - `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` + - `tests/Feature/ManagedTenantOnboardingWizardTest.php` + - `tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php` + - `tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php` + - `tests/Feature/Guards/OperationRunLinkContractGuardTest.php` +3. Anti-drift heavy-governance coverage: + - `tests/Architecture/PlatformVocabularyBoundaryGuardTest.php` + - `tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php` + - `tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php` + +## Focused Verification + +If you are reviewing the artifact set before implementation, start with the current repo baseline: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationTypeResolutionTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Onboarding/OnboardingDraftResolverTest.php +``` + +After the planned canonical-contract tests land, run the expanded narrow proving lane: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationTypeResolutionTest.php tests/Unit/Support/OperationRunTypeCanonicalContractTest.php tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Onboarding/OnboardingDraftResolverTest.php +``` + +Then run the representative operator-surface proof: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunListFiltersTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Monitoring/AuditCoverageOperationsTest.php tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php +``` + +Then run the guardrail proof that keeps `operation_type` platform-core, blocks raw alias drift, and preserves workspace or tenant non-leakage: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Architecture/PlatformVocabularyBoundaryGuardTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php +``` + +If PHP files were changed, finish with formatting: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +## Review Focus + +1. Confirm touched writers emit canonical dotted codes directly and do not rely on `canonicalCode()` as a second translation step. +2. Confirm filter options collapse alias variants into one canonical selection while still matching historical rows where required. +3. Confirm onboarding resume, save, and start behavior normalizes historical bootstrap selections and persists canonical codes only. +4. Confirm operations tenant-scope behavior, tenantless detail navigation, related-run links, summary widgets, and non-leakage guarantees remain intact after canonicalization. +5. Confirm DB-only monitoring render paths remain DB-only and do not introduce new query fan-out or render-time remote work. +6. Confirm audit-adjacent metadata, triage payloads, and runbook alerts stop copying raw `run->type` as current-release `operation_type` truth. +7. Confirm no new compatibility writer, no new table, no broader vocabulary cleanup, and no new asset or global-search change slipped into the slice. + +## Guardrail Close-Out + +- Validation before handoff should show that write-time truth is canonical, read-time compatibility is bounded, and raw alias drift is blocked by tests rather than reviewer memory alone. +- The close-out decision for this feature should remain `Guardrail` unless implementation expands into broader vocabulary or migration work, in which case the slice should be split. +- Implementation close-out keeps the bounded compatibility seam as `document-in-feature`: historical `operation_runs.type` aliases, queued-run reauthorization, filter matching, and persisted onboarding draft values are normalized on read, while provider registry/start paths and current-release writers reject or avoid legacy aliases. +- Broader provider/domain vocabulary cleanup remains a `follow-up-spec` boundary and is not part of this slice. diff --git a/specs/239-canonical-operation-type-source-of-truth/research.md b/specs/239-canonical-operation-type-source-of-truth/research.md new file mode 100644 index 00000000..09cd9faa --- /dev/null +++ b/specs/239-canonical-operation-type-source-of-truth/research.md @@ -0,0 +1,47 @@ +# Research: Canonical Operation Type Source of Truth + +## Decision 1: Promote `OperationCatalog` from canonical read helper to sole normative contract + +- **Decision**: Treat the dotted definitions in `App\Support\OperationCatalog` as the single platform-owned `operation_type` contract for the first implementation slice, and converge write owners on those values directly. +- **Rationale**: Repo reads show `OperationCatalog` already owns canonical dotted codes, labels, alias retirement metadata, and filter convergence, while `OperationRunType`, provider registry definitions, onboarding state, and lifecycle config still emit legacy aliases. Keeping `canonicalCode()` as a required second step preserves the dual-truth problem instead of removing it. +- **Alternatives considered**: Keep the current dual-semantics model where enum or registry values remain legacy aliases and callers translate later via `canonicalCode()`. Rejected because it continues to teach the wrong contract and keeps every new caller responsible for remembering the translation step. + +## Decision 2: Keep compatibility explicitly bounded and read-side only + +- **Decision**: The only allowed compatibility seam is read-time alias resolution for historical `operation_runs.type` rows and persisted onboarding draft state already present during rollout. +- **Rationale**: The spec explicitly requires historical readability, and repo truth shows legacy aliases still exist in stored rows and onboarding session state. Existing `OperationCatalog::resolve()` and onboarding normalization are the narrowest places to keep that seam without preserving aliases as current truth. +- **Alternatives considered**: + - Rewrite all historical rows and draft payloads as part of this slice. Rejected because it broadens the work into data backfill and migration cleanup outside the requested scope. + - Add dual-write or fallback writers. Rejected by the spec and by LEAN-001 because it would make the drift permanent. + +## Decision 3: First-slice write owners are concrete and already visible in repo hotspots + +- **Decision**: The first implementation pass should converge these concrete write owners before widening into additional consumers: `OperationRunType`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `ManagedTenantOnboardingWizard`, and `tenantpilot.operations.lifecycle.covered_types`. +- **Rationale**: Repo reads show each of these currently emits or persists raw aliases such as `inventory_sync`, `baseline_capture`, `entra_group_sync`, or `backup_schedule_run`. If they remain unchanged, touched read models will keep absorbing drift forever. +- **Alternatives considered**: Start by patching only list labels and filter options. Rejected because read-only cleanup would leave onboarding, provider dispatch, and lifecycle policy config still writing legacy values. + +## Decision 4: Raw type-specific branches and operator-adjacent metadata are part of the first pass + +- **Decision**: Replace or bound raw `type` comparisons and raw `operation_type` metadata copies in touched consumers such as `OperationRunResource`, `OperationRunLinks`, `OperationRunService`, `OperationRunTriageService`, and `FindingsLifecycleBackfillRunbookService`. +- **Rationale**: The repo already resolves labels through `OperationCatalog`, but several branches still compare against raw aliases like `baseline_compare`, `baseline_capture`, and `inventory_sync`, and several metadata payloads still emit `(string) $run->type` directly. Leaving those sites untouched would keep a hidden second truth even after primary writes are fixed. +- **Alternatives considered**: Limit scope to visible labels only. Rejected because audit-adjacent summaries, system triage metadata, and onboarding audit payloads are operator-facing evidence paths too. + +## Decision 5: Canonical dotted codes with underscore segments remain canonical and unchanged + +- **Decision**: Preserve existing dotted canonical codes that already contain underscore segments and explicitly treat them as in-bounds current-release truth, not cleanup debt for this spec. +- **Rationale**: Repo truth in `OperationCatalog` shows current canonical entries such as `backup_set.update`, `directory.role_definitions.sync`, `tenant.review_pack.generate`, `tenant.evidence.snapshot.generate`, `entra.admin_roles.scan`, and `rbac.health_check`. The spec explicitly forbids widening this slice into cosmetic segment renaming. +- **Alternatives considered**: Rename all embedded underscore segments while the contract is being hardened. Rejected because that turns one contract-hardening spec into a broader vocabulary rewrite. + +## Decision 6: Non-UI exports and summaries that still surface raw `type` must be called out explicitly + +- **Decision**: Treat the following non-UI or audit-adjacent payload sites as in-scope planning targets for canonical `operation_type` emission: `OperationRunService` audit recorder metadata, `OperationRunTriageService` triage audit metadata, `FindingsLifecycleBackfillRunbookService` alert metadata, and onboarding audit metadata (`operation_types` and `started_operation_type`). +- **Rationale**: The spec asked whether non-UI summaries still surface raw `operation_runs.type`. Repo reads confirm that they do, and these payloads influence operator and reviewer understanding even when they are not rendered in the primary table surface. +- **Alternatives considered**: Leave those payloads out of scope as “not UI.” Rejected because the feature is about one platform contract across monitoring, onboarding, references, and audit-adjacent summaries, not just visible table labels. + +## Decision 7: Keep proof in focused unit, feature, Livewire, and architecture lanes + +- **Decision**: Use focused unit coverage for canonical resolution and registry truth, focused feature and Livewire coverage for onboarding and operations filters, and architecture guard coverage for vocabulary drift. Do not require browser coverage for proof. +- **Rationale**: The repo hotspots and the spec show a shared-contract problem, not a browser-specific interaction problem. Existing tests already cover key surfaces such as `OperationTypeResolutionTest`, `OperationRunListFiltersTest`, `ManagedTenantOnboardingWizardTest`, `ManagedTenantOnboardingProviderStartTest`, and `PlatformVocabularyBoundaryGuardTest`. +- **Alternatives considered**: + - Browser proof for onboarding resume. Rejected as unnecessary for the first proving lane. + - Repo-wide grep bans for legacy aliases. Rejected because historical fixtures and read-side compatibility remain intentionally bounded and a blind string-ban would either fail valid cases or encourage exception lists. \ No newline at end of file diff --git a/specs/239-canonical-operation-type-source-of-truth/spec.md b/specs/239-canonical-operation-type-source-of-truth/spec.md new file mode 100644 index 00000000..869d8ffe --- /dev/null +++ b/specs/239-canonical-operation-type-source-of-truth/spec.md @@ -0,0 +1,359 @@ +# Feature Specification: Canonical Operation Type Source of Truth + +**Feature Branch**: `239-canonical-operation-type-source-of-truth` +**Created**: 2026-04-25 +**Status**: Implemented +**Input**: User description: "Canonical Operation Type Source of Truth" +**Mode**: Implementation close-out. This artifact now records the scoped contract, guardrails, acceptance criteria, and validation basis for the implemented slice. + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: The repo still has two competing truths for the same platform-owned operation identity: canonical dotted `operation_type` codes in the catalog and multiple raw storage or enum values such as `inventory_sync`, `baseline_capture`, `entra_group_sync`, and `backup_schedule_run` in in-scope writers and persisted workflow state. +- **Today's failure**: The same operation family can be started, persisted, filtered, audited, or reviewed under different identifiers, which teaches the wrong platform contract, makes rollout guards weaker, and lets monitoring, onboarding, provider dispatch, and read models drift apart. +- **User-visible improvement**: Operators and reviewers see one consistent operation identity across monitoring, onboarding, reporting, filters, and audit-adjacent surfaces, while historical rows continue to resolve correctly during the bounded rollout seam. +- **Smallest enterprise-capable version**: Promote the existing dotted catalog codes to the single normative `operation_type` contract, converge the current write owners around that contract, and allow only a narrowly bounded read-side compatibility seam for historical rows and draft state encountered during rollout. +- **Architecture center**: The primary deliverable is one platform-owned `operation_type` contract. Surface consistency, filter convergence, and drift guards are acceptance evidence for that contract, not a justification for new framework layers. +- **Explicit non-goals**: No new tables, no new operation family, no monitoring information-architecture redesign, no broad governed-subject vocabulary cleanup, no provider-neutral rewrite of every domain-specific operation name, and no long-lived dual-write or alias-preservation framework. +- **Permanent complexity imported**: One explicit single-source contract for `operation_type`, one temporary read-side compatibility seam during rollout, and focused guard coverage that blocks new raw alias writers from reappearing. +- **Why now**: This is the next unresolved anti-drift hotspot in the active governance hardening lane after Specs 237 and 238. The repo already classifies `operation_type` as a platform-core canonical noun, and later governed-subject vocabulary work depends on stabilizing this contract first. +- **Why not local**: The inconsistency is not isolated to one label or page. It crosses enum-backed writes, provider operation definitions, onboarding bootstrap workflow state, operation run read models, filter catalogs, audit metadata, and supporting guard tests. +- **Approval class**: Core Enterprise +- **Red flags triggered**: Foundation-sounding scope, many affected surfaces, and contract hardening in a shared platform seam. Defense: the slice stays tightly bounded to one already-existing contract, introduces no new persistence or framework, and prefers replacement over compatibility scaffolding. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace, tenant, canonical-view, platform +- **Primary Routes**: + - Existing workspace-admin Operations collection and drill-in surfaces anchored on `/admin/operations` + - Existing workspace-admin onboarding flow at `/admin/onboarding` + - Existing system Operations pages at `/system/ops/runs`, `/system/ops/failures`, `/system/ops/stuck`, and `/system/ops/runs/{run}` + - Existing shared widgets, related-navigation links, and audit-adjacent summaries that present operation identity on those surfaces +- **Data Ownership**: + - `operation_runs` remain the canonical persisted operational record; this spec does not add a new persisted run artifact + - `managed_tenant_onboarding_sessions.state.bootstrap_operation_types` remains existing workflow state and does not become a new source of truth + - `OperationCatalog`, filter options, labels, audit/reference summaries, and run presenters remain derived platform-core read models over canonical operation identity +- **RBAC**: + - Existing workspace membership remains required for workspace-admin operations and onboarding surfaces + - Existing tenant entitlement remains required for tenant-context onboarding behavior and any tenant-owned operation visibility + - Existing platform-user authorization remains required for `/system/ops/*` surfaces + - Existing operation-start capabilities remain authoritative; this spec introduces no new capability and does not change 404 versus 403 semantics + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: Covered admin operations and monitoring surfaces continue to prefilter to the active tenant when tenant-context is present, and their operation-type filters expose only canonical dotted choices rather than duplicate alias variants. +- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace and tenant entitlement checks remain authoritative before any operation label, filter result, or drill-in is revealed. Canonicalization must not create a new path that reveals inaccessible tenant-owned runs through filter options, counts, or read-model summaries. + +## 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 +- **Interaction class(es)**: operation labels, filter options, launch-surface selections, audit metadata, related-navigation summaries, run detail headings, and monitoring summaries +- **Systems touched**: `OperationCatalog`, `OperationRunType`, provider operation definitions and start-gate payloads, onboarding bootstrap selection and resume state, filter option catalogs, operations list/detail surfaces, system ops pages, operation references, audit summaries, and workspace/dashboard summaries +- **Existing pattern(s) to extend**: the existing `OperationCatalog` canonical definitions and alias inventory, `FilterOptionCatalog::operationTypes()`, `OperationUxPresenter`, `OperationRunService`, and the current `operation_type` glossary entry in the platform vocabulary inventory +- **Shared contract / presenter / builder / renderer to reuse**: `OperationCatalog` remains the sole shared contract for canonical operation identity, labels, alias retirement metadata, and filter-option convergence; existing presenters and surfaces continue to read through that path instead of inventing a second translation layer +- **Why the existing shared path is sufficient or insufficient**: the existing shared path is sufficient as the canonical catalog and operator-label source, but it is insufficient as long as enum-backed writers, provider operation definitions, and onboarding state still emit raw aliases as peer truths +- **Allowed deviation and why**: a narrowly bounded read-side compatibility seam is allowed only for historical `operation_runs.type` rows and persisted onboarding draft state encountered during rollout; no new write-side aliasing, dual-write, or long-lived preservation path is allowed +- **Consistency impact**: monitoring filters, run labels, audit metadata, onboarding bootstrap state, provider operation definitions, reference summaries, and start-gate payloads must all agree on the same canonical dotted `operation_type` +- **Review focus**: reviewers must block any new underscore-style or alternate raw identifier from becoming write-time or registry-time truth, and must verify that compatibility stays read-side only and explicitly temporary + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: yes +- **Shared OperationRun UX contract/layer reused**: the existing shared start and label path owned by `OperationRunService`, `OperationUxPresenter`, and `OperationCatalog` +- **Delegated start/completion UX behaviors**: queued toast, `Open operation` deep link, run-enqueued browser event, queued DB-notification decision, dedupe-or-blocked messaging, and tenant-safe run URL resolution remain delegated to the existing shared OperationRun UX path; this slice changes the operation identity it receives, not the UX ownership model +- **Local surface-owned behavior that remains**: launch surfaces such as the onboarding bootstrap step keep only initiation inputs and selection state; they do not own parallel operation-type translation rules +- **Queued DB-notification policy**: existing explicit opt-in only; unchanged +- **Terminal notification path**: existing central lifecycle mechanism; unchanged +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: yes +- **Boundary classification**: mixed +- **Seams affected**: platform-core `operation_type` vocabulary, provider operation registry definitions and bindings, operation start-gate payloads, monitoring and reporting read models, audit metadata, and onboarding bootstrap selections +- **Neutral platform terms preserved or introduced**: `operation`, `operation_type`, canonical operation code, operation catalog, operation label +- **Provider-specific semantics retained and why**: provider-owned operation families such as Microsoft-specific `entra.*` codes remain valid current-release canonical codes where the operation itself is still provider-owned; this slice stabilizes shared contract ownership rather than renaming every provider-specific family +- **Why this does not deepen provider coupling accidentally**: the contract becomes stricter about one platform-owned identity path and removes peer truths; it does not generalize Microsoft semantics into platform core and does not require provider-specific codes to leak into generic vocabulary beyond their already-bounded domain ownership +- **Follow-up path**: follow-up-spec for broader governed-subject vocabulary enforcement and any later provider/domain naming cleanup that remains after this contract lands + +## 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 | +|---|---|---|---|---|---|---| +| Workspace-admin Operations list and drill-ins | yes | Native Filament resource plus shared monitoring shell | shared operations family | table, detail, filter, read-model summary | no | Canonical filter options and labels must collapse legacy aliases into one operation identity | +| System Ops pages (`Runs`, `Failures`, `Stuck`, `ViewRun`) | yes | Native Filament pages plus shared ops widgets | shared operations family | table, detail, widget summary | no | The same canonical operation identity must drive triage labels and filters across system views | +| Managed tenant onboarding bootstrap step | yes | Native Filament wizard plus shared start UX | shared onboarding and operation-launch family | wizard step, persisted workflow state | no | Bootstrap selections persist and resume as canonical codes only | +| Shared operation references and dashboard widgets | yes | Native widgets plus shared reference presenters | shared operations family | related link, widget card, summary text | no | Secondary context surfaces inherit the same canonical operation identity without local alias mapping | + +## 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 | +|---|---|---|---|---|---|---|---| +| Workspace-admin Operations list and drill-ins | Primary Decision Surface | Decide what ran, whether it needs follow-up, and which run to inspect | operation label, status, outcome, initiator, started time, tenant scope | run context, artifact links, diagnostic detail, related navigation | Primary because this is the main workspace-admin run register where operators triage execution truth | Follows monitoring and troubleshooting workflow rather than storage internals | Removes duplicate alias choices and prevents the same operation family from appearing as different identities | +| System Ops pages | Primary Decision Surface | Decide which failed or stuck run needs platform/support attention | operation label, failure/stuck class, scope, recency, current status | low-level diagnostics and raw context on dedicated drill-ins | Primary because these are the platform triage surfaces for operational exceptions | Follows platform-ops triage workflow directly | Avoids re-learning alias variants across `Runs`, `Failures`, and `Stuck` views | +| Managed tenant onboarding bootstrap step | Primary Decision Surface | Decide which bootstrap operations to start for the tenant | selected bootstrap actions, operability state, permission availability | started run detail and post-start diagnostics | Primary because the operator is choosing and launching bootstrap work here | Follows onboarding progression rather than monitoring structure | Prevents saved selections and resumed drafts from drifting back to legacy raw identifiers | +| Shared operation references and dashboard widgets | Secondary Context Surface | Decide whether to open a referenced operation from another workflow surface | operation label, health summary, recency | full run detail after drill-in | Secondary because these surfaces support a larger owning workflow and route into operations detail | Keeps navigation tied to the owning surface while reusing canonical operation identity | Reduces mental translation between widgets, references, and the operations register | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Workspace-admin Operations list and drill-ins | List / Table / Bulk | Read-only Registry / Monitoring | Open one operation and inspect its result | Existing admin operation detail drill-in from the operations register | required | Existing safe secondary actions stay in `More` or the detail header only | Existing destructive-like resumable actions stay where already approved on detail surfaces | `/admin/operations` | existing admin operation detail drill-in | workspace, tenant filter, problem class, recency | Operations / Operation | operation label, status, outcome, tenant, and started time | none | +| System Ops pages | List / Table / Bulk | Monitoring / Triage Report | Open a failed or stuck operation for deeper diagnosis | Explicit system run drill-in | allowed | Existing secondary navigation stays in header or explicit row context only | none added | `/system/ops/runs`, `/system/ops/failures`, `/system/ops/stuck` | `/system/ops/runs/{run}` | platform scope, tenant/workspace context, problem class | Operations / Operation | failure/stuck state, operation label, scope, and recency | none | +| Managed tenant onboarding bootstrap step | Workflow / Wizard / Launch | Wizard Step / Launch Surface | Start the selected bootstrap operations | The onboarding step itself before launch, then the existing run link after start | forbidden | Secondary guidance lives in step text and follow-up summaries, not competing actions | none added | `/admin/onboarding` | existing operation detail drill-in after launch | workspace, tenant, verification readiness, bootstrap selection | Operations / Operation | which bootstrap operations will start and whether they are allowed | none | +| Shared operation references and dashboard widgets | Embedded Related Navigation | Related Link / Summary Card | Open the referenced operation | Explicit safe link only | forbidden | Footer or contextual link only | none | owning surface route | existing admin or system operation detail route | owning-surface context plus tenant/workspace scope | Operations / Operation | operation label, current health summary, recency | 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Workspace-admin Operations list and drill-ins | Workspace owner, manager, operator | Decide whether a run needs follow-up and inspect the correct run record | List and detail | What ran, and what needs attention? | operation label, status, outcome, initiator, tenant, started time | raw context, artifact detail, related navigation, low-level explanation | lifecycle, outcome, readiness/attention | inspection only until an existing approved follow-up action is chosen | Open operation, existing safe detail actions | Existing detail-owned dangerous actions only where already approved | +| System Ops pages | Platform operator or support reviewer | Decide which failed or stuck platform-visible run needs triage | List and detail | Which operation needs platform attention right now? | operation label, failure/stuck class, scope, recency | extended diagnostics and raw execution context on drill-in | lifecycle, failure/stuck state, recency | inspection only | Open operation, existing page-level navigation | none added | +| Managed tenant onboarding bootstrap step | Workspace owner or tenant manager | Decide which bootstrap operations to start and resume safely | Wizard step | Which bootstrap operations should this tenant start now? | selected bootstrap actions, permission state, existing bootstrap summary | run detail after launch, deeper diagnostics through existing run links | readiness, queued/running/completed bootstrap state | starts existing tenant operations only | Start bootstrap, continue onboarding | none added | +| Shared operation references and dashboard widgets | Workspace operator, reviewer, or platform operator | Decide whether to drill into a referenced operation from the owning surface | Embedded summary | Do I need to open this operation from here? | operation label, summary state, recency | full run detail after navigation | summary state, recency | none | Open operation | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: yes, in contract terms only; the existing dotted catalog becomes the single normative `operation_type` truth instead of one of two competing truths +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: no +- **New enum/state/reason family?**: no +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Operators and reviewers cannot reliably tell whether two differently named run identities refer to the same operation family, and maintainers can accidentally reintroduce raw aliases into onboarding, provider dispatch, or monitoring flows because the contract is still split. +- **Existing structure is insufficient because**: `OperationCatalog` already defines canonical dotted meanings, but `OperationRunType`, provider operation definitions, and onboarding bootstrap state still treat raw aliases as first-class write truths, which means every consumer must keep translating and can drift independently. +- **Narrowest correct implementation**: Reuse the existing catalog as the single contract, converge the current write owners and read models around it, and keep only a bounded read-side compatibility seam for historical rows or draft state encountered during rollout. +- **Ownership cost**: Existing fixtures, enum-backed assumptions, onboarding tests, and provider operation guards must be updated to one canonical contract, and the temporary compatibility seam must be retired instead of silently becoming permanent. +- **Alternative intentionally rejected**: Keeping long-lived dual semantics behind `canonicalCode()` translation or adding a broader new resolver framework was rejected because both preserve drift rather than removing it. A wider vocabulary cleanup was rejected because it would broaden scope beyond `operation_type` truth. +- **Release truth**: current-release truth and anti-drift hardening, not future speculative generalization + +### Compatibility posture + +This feature assumes a pre-production environment. + +Canonical replacement is preferred over preservation. + +The only allowed compatibility seam is a narrowly bounded read-side translator for historical `operation_runs.type` rows and persisted onboarding state encountered during rollout. No new dual-write, no new fallback writer, and no new long-lived legacy alias fixture policy are allowed. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit, Feature, Heavy-Governance +- **Validation lane(s)**: fast-feedback, confidence, heavy-governance +- **Why this classification and these lanes are sufficient**: the contract change is proven by narrow resolution and guard behavior plus representative operator-facing surfaces. Unit coverage proves canonical-code ownership, alias resolution, and registry normalization. Focused feature coverage proves monitoring filters, onboarding resume/start behavior, DB-only operations rendering, tenant-safe detail navigation, and operation-facing labels or read models. Existing heavy-governance families prove platform vocabulary anti-drift plus workspace and tenant non-leakage on touched operations surfaces. +- **New or expanded test families**: focused operation-type contract guards, monitoring/filter convergence coverage, onboarding bootstrap canonicalization coverage, provider operation registry canonical-code coverage, and existing heavy-governance non-leakage coverage on touched operations surfaces +- **Fixture / helper cost impact**: low to moderate; implementation should reuse existing `OperationRun`, onboarding session, workspace, tenant, and provider fixtures while shrinking legacy alias fixtures over time rather than adding a new helper layer +- **Heavy-family visibility / justification**: existing heavy-governance families in `tests/Architecture` and `tests/Feature/OpsUx` are touched because the feature changes platform-core vocabulary and tenant-safe operation visibility; no new heavy family is introduced +- **Special surface test profile**: monitoring-state-page plus standard-native-filament onboarding coverage +- **Standard-native relief or required special coverage**: ordinary feature coverage remains sufficient for the primary operator surfaces; browser proof is not required. Existing heavy-governance families remain necessary for anti-drift and non-leakage proof. +- **Reviewer handoff**: reviewers must confirm that write-time callers stop emitting raw aliases, filter options collapse duplicate alias variants into one canonical choice, shared links and summary widgets preserve the same canonical operation identity, onboarding resume normalizes historical selections, operations tenant-scope and tenantless viewer behavior remain entitlement-safe, DB-only operations rendering stays DB-only, and compatibility remains read-side only and temporary +- **Budget / baseline / trend impact**: small increase in focused fast-feedback and confidence coverage plus reuse of existing heavy-governance families; no new heavy family or browser lane +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `Current repo baseline before implementation: export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationTypeResolutionTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Onboarding/OnboardingDraftResolverTest.php` + - `Post-implementation expanded unit proof after the planned canonical-contract tests land: export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationTypeResolutionTest.php tests/Unit/Support/OperationRunTypeCanonicalContractTest.php tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Onboarding/OnboardingDraftResolverTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunListFiltersTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Monitoring/AuditCoverageOperationsTest.php tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Architecture/PlatformVocabularyBoundaryGuardTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - See One Operation Identity Everywhere (Priority: P1) + +As a workspace or platform operator, I want one consistent operation identity behind monitoring labels, filters, and references so I can trust that the same operation family is not being split across multiple raw names. + +**Why this priority**: This is the primary trust and workflow problem. If monitoring and related surfaces still treat `inventory_sync` and `inventory.sync` as different truths, the platform keeps teaching the wrong contract. + +**Independent Test**: Can be fully tested by loading covered operations surfaces with historical and current run rows and confirming that one canonical operation identity and one label source are used for the same operation family. + +**Acceptance Scenarios**: + +1. **Given** historical runs contain a raw alias such as `inventory_sync` and newer runs contain `inventory.sync`, **When** the operator opens a covered operations surface, **Then** both resolve under one canonical operation identity and one filter choice rather than appearing as separate operation families. +2. **Given** a related-navigation link or dashboard widget references an operation run, **When** the operator views it, **Then** the operation label comes from the same canonical source used by the operations register. + +--- + +### User Story 2 - Resume Onboarding Without Rewriting Legacy Drift (Priority: P1) + +As a workspace admin using onboarding bootstrap actions, I want saved selections and resumed drafts to normalize to canonical operation codes so the wizard does not keep writing legacy raw values back into the platform. + +**Why this priority**: Onboarding is an active write surface today. If it keeps persisting legacy aliases, the platform contract cannot converge even if monitoring labels do. + +**Independent Test**: Can be fully tested by opening the bootstrap step with historical draft state, resuming the wizard, saving again, and starting bootstrap actions to verify canonical codes are used for persistence and start payloads. + +**Acceptance Scenarios**: + +1. **Given** an onboarding draft contains legacy bootstrap selections such as `inventory_sync`, **When** the wizard resumes and the operator saves or starts bootstrap, **Then** the persisted selection is canonicalized to the dotted code and no new legacy write is produced. +2. **Given** the operator starts bootstrap actions from the onboarding step, **When** the run start is delegated, **Then** the shared OperationRun UX path receives canonical operation codes only. + +--- + +### User Story 3 - Extend Operation Families Without Creating Peer Truths (Priority: P2) + +As a maintainer or reviewer, I want one platform-owned operation-type contract so future provider or monitoring work cannot silently reintroduce raw alias writers or alternate registry truth. + +**Why this priority**: The feature is justified not only by current operator clarity but by preventing the same semantic hotspot from reopening as more governance and provider work lands. + +**Independent Test**: Can be fully tested by exercising focused guard coverage that fails when a new in-scope writer or registry definition emits a raw alias as first-class operation identity. + +**Acceptance Scenarios**: + +1. **Given** a new in-scope operation definition or launch path is added using a legacy raw alias, **When** focused guard coverage runs, **Then** the change fails because the platform contract is no longer allowed to accept that alias as write-time truth. +2. **Given** historical rows still contain legacy aliases, **When** read models resolve them, **Then** the compatibility seam remains read-side only and does not turn those aliases back into approved write-time contract values. + +### Edge Cases + +- Historical `operation_runs.type` rows may still contain aliases such as `baseline_capture`, `baseline_compare`, `inventory_sync`, `entra_group_sync`, or `backup_schedule_run`, and covered read surfaces must still resolve them correctly during rollout. +- Persisted onboarding draft state may already contain alias arrays such as `inventory_sync`, and resume behavior must normalize them without silently writing the old value back. +- Some existing canonical dotted codes already contain underscore segments, such as `backup_set.update` or `tenant.review_pack.generate`; this slice must not accidentally widen into a cosmetic segment-rename campaign. +- Unknown or unsupported operation types must remain explicitly unknown rather than falling through to a misleading nearby label. +- A single canonical filter option may need to match multiple historical raw values, and covered surfaces must not expose duplicate filter choices or contradictory counts. +- Authorization boundaries must remain intact even when canonicalization collapses multiple stored raw values into one visible operation identity. + +## Acceptance Criteria Summary + +- Covered write surfaces emit only canonical dotted `operation_type` codes after implementation. +- Covered read surfaces collapse legacy aliases into one canonical operation identity without duplicate filter choices. +- Historical rows and draft state remain readable during rollout through a bounded read-side seam only. +- Guard coverage blocks the reintroduction of raw alias writers or alternate registry truth on in-scope paths. + +## Implementation Close-Out + +- Covered current-release writers now emit canonical dotted values directly through `OperationRunType`, provider registry/start paths, onboarding bootstrap starts, lifecycle config, and representative inventory, backup, evidence, review, baseline, and directory writers. +- The remaining compatibility seam is documented as read-side only for historical `operation_runs.type` rows, queued-run reauthorization, filter matching, and persisted onboarding draft normalization; legacy aliases remain `write_allowed: false`. +- Validation completed through focused US1, US2, US3, and affected writer/queue regression lanes before final quickstart validation. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature changes the contract around existing long-running operations, not the existence of those operations. Any in-scope start, read-model, or audit change must preserve current `OperationRun` observability, tenant/workspace isolation, auditability, and service-owned lifecycle behavior while canonicalizing `operation_type` identity. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature promotes an existing platform-core operation catalog to the single normative contract and removes peer truths. It does not add new persistence, new states, new registries, or a second abstraction layer. + +**Constitution alignment (XCUT-001):** This is cross-cutting across monitoring labels, filter options, launch surfaces, onboarding bootstrap state, audit metadata, reference summaries, and provider operation definitions. It must reuse the existing shared `OperationCatalog` path rather than create a parallel translator or presenter family. + +**Constitution alignment (PROV-001):** `operation_type` remains platform-core. Provider operation bindings continue to describe provider-owned behavior, but they must consume canonical platform-owned operation codes rather than introduce alternate raw identifiers as shared truth. + +**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit, feature, and existing heavy-governance lanes. No browser lane and no new heavy-governance family are justified. New coverage must make alias drift, non-leakage, and DB-only render safety explicit without expanding default fixture cost or creating a new test-helper framework. + +**Constitution alignment (OPS-UX):** This feature reuses existing `OperationRun` behavior. The default 3-surface feedback contract remains intact, `OperationRun.status` and `OperationRun.outcome` remain service-owned through `OperationRunService`, `summary_counts` semantics remain unchanged, and scheduled/system-run terminal notification behavior remains unchanged. + +**Constitution alignment (OPS-UX-START-001):** The feature includes the `OperationRun UX Impact` section and keeps start-surface behavior delegated to the existing shared path. Local surfaces are limited to initiation inputs and canonical operation selection; they do not own queued toast, link, browser-event, or notification behavior. + +**Constitution alignment (RBAC-UX):** Authorization planes remain unchanged. Non-members remain 404, members lacking capability remain 403, and covered operations/global references remain tenant-safe. No destructive confirmation rule changes are introduced. + +**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. + +**Constitution alignment (BADGE-001):** Existing run status and outcome badges remain centralized and unchanged. This slice does not introduce a new badge family or local status mapping. + +**Constitution alignment (UI-FIL-001):** UI-FIL-001 is satisfied. Covered admin and system surfaces stay on native Filament resources, pages, widgets, forms, and sections plus existing shared presenters. No local replacement markup or local status language is introduced. + +**Constitution alignment (UI-NAMING-001):** The target object is the operation and its platform `operation_type` identity. Operator-facing labels continue to use existing operation display labels derived from the canonical catalog. Raw storage aliases remain implementation details and must not become primary operator-facing copy. + +**Constitution alignment (DECIDE-001):** Operations registers and onboarding bootstrap remain primary decision surfaces. Shared references and widgets remain secondary context surfaces that route to the same canonical run truth. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The affected surface classes, inspect models, routes, scope cues, default-visible truth, and action placements are captured in the surface tables above. No exemption is required. + +**Constitution alignment (ACTSURF-001 - action hierarchy):** This slice does not add a new action family. Existing navigation, mutation, and dangerous-action placement remain unchanged while canonical operation identity is normalized underneath them. + +**Constitution alignment (OPSURF-001):** Default-visible content remains operator-first on covered surfaces. Operators continue to see labels, status, outcome, and scope rather than raw alias strings. Diagnostic identity details remain secondary. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from the canonical operation catalog to UI labels and filters is sufficient. The slice exists to remove redundant truth, not add another interpretation layer. + +**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is satisfied. Each affected surface keeps one primary inspect/open model, redundant `View` actions remain absent, empty action groups remain absent, and destructive action placement remains unchanged. The UI Action Matrix below records the affected surfaces. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** Covered Filament screens keep their existing native layouts, sections, tables, and empty-state behavior. No UX-001 exemption is required because this slice changes contract truth and shared labels/filters rather than introducing new layouts. + +### Functional Requirements + +- **FR-239-001**: Existing catalog-defined dotted `operation_type` codes MUST become the single normative platform contract for in-scope operation identity. +- **FR-239-002**: All new or updated in-scope operation starts, provider operation definitions, audit metadata payloads, and onboarding bootstrap selections MUST emit or persist canonical dotted codes rather than raw legacy aliases. +- **FR-239-003**: Existing canonical dotted codes that already contain underscore segments, such as `backup_set.update` or `tenant.review_pack.generate`, MUST remain valid canonical codes in this slice; this feature MUST NOT widen into a cosmetic renaming campaign. +- **FR-239-004**: No in-scope write-time caller MAY require a second translation step such as `canonicalCode()` to discover the normative platform contract after implementation; the emitted or stored value itself must already be canonical. +- **FR-239-005**: `OperationCatalog` MUST remain the sole shared resolver for canonical operation code, operator label, alias retirement metadata, and filter-option convergence. +- **FR-239-006**: Historical raw values MAY be resolved only at read time for legacy `operation_runs.type` rows or persisted onboarding draft state encountered during rollout. +- **FR-239-007**: The compatibility seam for historical aliases MUST be bounded, documented, and removable; no dual-write, fallback writer, or long-lived alias-preservation framework is allowed. +- **FR-239-008**: Covered operations collection and detail surfaces MUST collapse alias variants into one canonical operation identity and one operator label source. +- **FR-239-009**: Covered monitoring, reporting, reference, and audit surfaces that describe an operation type MUST derive from the canonical code or its catalog label rather than rendering raw legacy aliases as first-class truth. +- **FR-239-010**: The onboarding bootstrap step MUST normalize legacy saved selections on resume and MUST persist only canonical dotted codes after any in-scope edit, save, or start action. +- **FR-239-011**: Provider operation registries, bindings, and start-gate payloads MUST define `operation_type` using canonical dotted codes rather than raw aliases. +- **FR-239-012**: Unknown or unsupported operation types MUST remain explicitly unknown and MUST NOT silently inherit a nearby canonical label or alias match. +- **FR-239-013**: Existing `OperationRun` lifecycle, dedupe, notification, and authorization behavior MUST remain unchanged except for canonical operation-type identity. +- **FR-239-014**: Existing 404 versus 403 semantics across workspace-admin, tenant-context, and system operations surfaces MUST remain unchanged. +- **FR-239-015**: The first implementation slice MUST cover `OperationRunType`, `OperationCatalog`, provider operation definitions and start-gate consumers, onboarding bootstrap state, operations filter/read-model helpers, and focused guard coverage. +- **FR-239-016**: The feature MUST NOT add new tables, new operation families, new provider abstraction frameworks, or a broader monitoring information-architecture redesign. +- **FR-239-017**: Regression coverage MUST fail if a new underscore-style or otherwise non-canonical write-time identifier becomes accepted as platform-core truth on any covered writer or registry path. +- **FR-239-018**: `operation_type` MUST remain classified as a platform-core canonical noun, while `operation_runs.type` remains only a temporary compatibility storage seam during rollout. +- **FR-239-019**: Later vocabulary cleanup outside `operation_type` truth, including broader governed-subject key work and unrelated domain naming, MUST remain outside this slice. + +## Non-Goals + +- Renaming every existing canonical code segment to eliminate embedded underscores +- Reworking operation navigation, dashboard information architecture, or monitoring page layout +- Introducing a new registry, resolver, presenter, or taxonomy framework for operation identity +- Adding new operation families or expanding provider-neutral runtime scope +- Performing application-code implementation in this spec artifact + +## Assumptions + +- LEAN-001 applies: the product is still pre-production, so canonical replacement is preferred over preserving raw alias writers. +- The existing `OperationCatalog` dotted definitions are the closest current platform truth and are sufficient as the single normative contract for this slice. +- Historical `operation_runs.type` rows and persisted onboarding drafts may still contain legacy raw aliases when implementation starts. +- Operator-facing copy continues to use current display labels derived from the canonical catalog rather than exposing dotted codes by default, except on surfaces that intentionally show identifiers. +- Existing workspace, tenant, and platform authorization rules already cover the affected surfaces and do not need redesign for this slice. + +## Dependencies + +- `specs/171-operations-naming-consolidation/spec.md` for operator-visible naming alignment already completed separately from internal operation-type truth +- `specs/237-provider-boundary-hardening/spec.md` for shared provider/platform seam discipline +- `specs/238-provider-identity-target-scope/spec.md` as the adjacent anti-drift lane that keeps provider-neutral platform truth bounded correctly +- Existing platform vocabulary classification that marks `operation_type` as a platform-core canonical noun and `operation_runs.type` as a rollout compatibility seam +- Existing operations, onboarding, provider-dispatch, reference, and audit surfaces that already consume operation identity today + +## Risks + +- A bounded read-side compatibility seam could accidentally become permanent if in-scope write paths are not fully converged in the same implementation slice. +- The slice can sprawl into broader vocabulary cleanup unless planning and review keep the scope locked to `operation_type` truth only. +- Partial convergence across monitoring, onboarding, and provider dispatch would still leave contradictory platform truth even if one surface appears fixed. +- Existing fixtures and historical assumptions may be numerous enough that reviewers are tempted to preserve alias writers instead of replacing them. + +## Resolved Planning Notes + +- Canonical dotted codes that legitimately retain underscore segments and must not trigger broader renaming include `backup_set.update`, `directory.role_definitions.sync`, `tenant.review_pack.generate`, `tenant.evidence.snapshot.generate`, `entra.admin_roles.scan`, and `rbac.health_check`. +- The first implementation pass must explicitly cover non-UI exports and stored summaries that still surface raw `operation_runs.type`, including `OperationRunService`, `OperationRunTriageService`, `FindingsLifecycleBackfillRunbookService`, shared related-run links, and summary-widget read models. + +## Follow-up Candidates + +- Platform Vocabulary Boundary Enforcement for Governed Subject Keys +- Retirement of the bounded read-side compatibility seam once historical rows and fixtures have been replaced +- Any later provider/domain vocabulary cleanup that remains necessary after the canonical `operation_type` contract is fully converged +- Customer Review Workspace v1 and broader review-surface work after this platform-core semantic drift is removed + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Workspace-admin Operations surfaces | existing admin operations resource and tenantless detail viewer | no new header actions | existing clickable-row or safe detail drill-in | existing inspect action(s) only | existing grouped bulk behavior remains unchanged | existing empty-state behavior unchanged | existing back, refresh, related links, and approved resumable actions remain | n/a | no new audit behavior | Only the canonical operation-type contract behind labels and filters changes | +| System Ops pages | existing system pages for runs, failures, stuck, and run detail | no new header actions | existing view-run drill-in | existing inspect action only | none added | existing page behavior unchanged | existing page-level navigation and refresh remain | n/a | no new audit behavior | Canonical operation-type filters and labels must match workspace-admin operations truth | +| Managed tenant onboarding bootstrap step | existing onboarding wizard bootstrap step | none added | the wizard step itself before launch; existing operation link after launch | `Start bootstrap` remains existing launch action | none | existing onboarding CTA behavior remains | n/a | existing continue/cancel behavior remains | existing onboarding audit behavior remains | Saved selections and resumed drafts normalize to canonical codes only | +| Shared operation references and widgets | existing dashboard widgets and reference presenters | no new header actions | explicit safe link only | existing safe inspect link only | none | owning surface behavior unchanged | n/a | n/a | no new audit behavior | Secondary surfaces must not add local alias mapping or duplicate identity rules | + +### Key Entities *(include if feature involves data)* + +- **Canonical Operation Type**: The single platform-owned dotted identifier that names an operation family consistently across starts, persistence, read models, filters, audit metadata, and operator-facing labels. +- **Historical Operation Type Alias**: A legacy raw value such as `inventory_sync` or `baseline_capture` that may still appear in older persisted rows or workflow state and is allowed only through the temporary read-side compatibility seam. +- **Operation Run**: The existing persisted operational record whose lifecycle, auditability, and visibility stay unchanged while its canonical operation identity is tightened. +- **Onboarding Bootstrap Selection**: The existing workflow-state list of optional bootstrap operations chosen during onboarding and normalized to canonical operation codes before persistence or launch. +- **Provider Operation Definition**: The existing provider-backed operation registry or binding entry that must consume canonical `operation_type` identity without becoming a second truth owner. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-239-001**: In all covered write paths, 100% of newly emitted or persisted in-scope operation identifiers use canonical dotted `operation_type` codes rather than legacy raw aliases. +- **SC-239-002**: On all covered monitoring and operations surfaces, 100% of alias variants for the same operation family collapse into one canonical operation identity and one filter choice. +- **SC-239-003**: Historical rows and resumed onboarding drafts using legacy aliases remain readable throughout rollout without producing any new legacy write on a covered save or start path. +- **SC-239-004**: Focused regression coverage fails whenever a covered writer, registry entry, or launch surface attempts to reintroduce a raw alias as first-class platform truth. +- **SC-239-005**: The feature lands without adding any new table, new operation family, new abstraction framework, or long-lived dual-write compatibility layer. + +## Definition of Done + +Spec 239 is complete when the platform has one normative dotted `operation_type` contract for covered operations, historical alias rows remain readable only through a bounded read-side seam during rollout, onboarding and provider-dispatch surfaces stop writing legacy raw values, covered operations surfaces expose one canonical identity per operation family, and focused automated coverage blocks the semantic drift from returning. diff --git a/specs/239-canonical-operation-type-source-of-truth/tasks.md b/specs/239-canonical-operation-type-source-of-truth/tasks.md new file mode 100644 index 00000000..feef14a6 --- /dev/null +++ b/specs/239-canonical-operation-type-source-of-truth/tasks.md @@ -0,0 +1,242 @@ +--- +description: "Task list for Canonical Operation Type Source of Truth" +--- + +# Tasks: Canonical Operation Type Source of Truth + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/239-canonical-operation-type-source-of-truth/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/239-canonical-operation-type-source-of-truth/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/239-canonical-operation-type-source-of-truth/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/239-canonical-operation-type-source-of-truth/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/239-canonical-operation-type-source-of-truth/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/239-canonical-operation-type-source-of-truth/contracts/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/239-canonical-operation-type-source-of-truth/quickstart.md` + +**Tests**: REQUIRED (Pest) for runtime behavior changes; keep proof in the narrow `Unit`, `Feature`, and existing `Heavy-Governance` lanes named in the plan +**Operations**: No new `OperationRun` family, panel, or Monitoring IA is introduced; preserve the shared OperationRun Start UX contract while canonicalizing `operation_type` across touched write and read surfaces +**RBAC**: Preserve existing workspace, tenant, and platform authorization semantics on operations and onboarding surfaces, including unchanged `404` versus `403` behavior +**Provider Boundary**: `operation_type` remains platform-core; provider operation bindings stay provider-owned and may not reintroduce raw aliases as shared truth + +**Organization**: Tasks are grouped by user story so canonical contract owners, monitoring parity, onboarding bootstrap state, provider-start writers, audit-adjacent summaries, and anti-drift guardrails can be implemented and validated incrementally without widening into broader naming cleanup or a generic compatibility framework. + +## Test Governance Checklist + +- [X] Lane assignment stays `Unit` plus `Feature` plus existing `Heavy-Governance` and remains the narrowest sufficient proof for the changed behavior. +- [X] New or changed tests stay in existing support, provider, onboarding, monitoring, and guard families; existing `tests/Architecture` and `tests/Feature/OpsUx` heavy-governance families may be reused, but no new heavy-governance family or browser lane is added. +- [X] Shared helpers, factories, seeds, onboarding draft defaults, and operation fixtures stay cheap by default; no new alias-preserving helper layer is introduced. +- [X] Planned validation commands cover canonical resolution, operations filter parity, tenant-safe operations visibility, DB-only monitoring rendering, onboarding bootstrap lifecycle, provider-start truth, and guardrails without widening scope. +- [X] The declared surface test profile remains `monitoring-state-page` plus `standard-native-filament`; no exception-coded surface or browser proof is required. +- [X] Any remaining compatibility seam resolves as `document-in-feature` or `follow-up-spec`, not as a silent generic compatibility framework. + +## Phase 1: Setup (Shared Context) + +**Purpose**: Confirm the exact contract hotspots, owning files, and existing proof lanes before code changes begin. + +- [X] T001 Review the current canonical contract owners in `apps/platform/app/Support/OperationRunType.php`, `apps/platform/app/Support/OperationCatalog.php`, and `apps/platform/config/tenantpilot.php` +- [X] T002 [P] Review representative write owners and onboarding bootstrap state in `apps/platform/app/Services/Providers/ProviderOperationRegistry.php`, `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, and `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php` +- [X] T003 [P] Review monitoring, filter, reference, audit, tenant-scope, and DB-only proof hotspots in `apps/platform/app/Filament/Resources/OperationRunResource.php`, `apps/platform/app/Support/Filament/FilterOptionCatalog.php`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/References/Resolvers/OperationRunReferenceResolver.php`, `apps/platform/app/Services/Audit/AuditEventBuilder.php`, `apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`, `apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`, and `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Collapse the shared canonical contract onto one platform-owned truth before any user story work begins. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [X] T004 [P] Add or extend canonical resolution and unknown-label coverage in `apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php` and `apps/platform/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php` +- [X] T005 [P] Add enum and vocabulary anti-drift coverage in `apps/platform/tests/Unit/Support/OperationRunTypeCanonicalContractTest.php` and `apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php` +- [X] T006 Update `apps/platform/app/Support/OperationRunType.php` and `apps/platform/app/Support/OperationCatalog.php` so canonical dotted `operation_type` values are the only normative write-time contract and aliases remain read-side only +- [X] T007 Update `apps/platform/config/tenantpilot.php` and `apps/platform/app/Support/Operations/OperationLifecyclePolicy.php` so covered lifecycle keys and policy lookups use canonical dotted `operation_type` values directly + +**Checkpoint**: Shared canonical contract owners and guard foundations are ready; user story work can now proceed. + +--- + +## Phase 3: User Story 1 - See One Operation Identity Everywhere (Priority: P1) 🎯 MVP + +**Goal**: Make operations lists, filters, references, and audit-adjacent monitoring summaries resolve one canonical operation identity instead of teaching alias variants as peer truths. + +**Independent Test**: Load covered operations surfaces with historical and current rows and verify one canonical filter choice, one operator label source, and one canonical summary identity for the same operation family. + +### Tests for User Story 1 + +- [X] T008 [P] [US1] Extend canonical filter convergence coverage in `apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php` +- [X] T009 [P] [US1] Extend monitoring, widget-summary, related-link, unknown-label, tenant-scope, tenantless-viewer, DB-only, and audit-adjacent canonicalization coverage in `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php`, `apps/platform/tests/Feature/Monitoring/AuditCoverageOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`, `apps/platform/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php`, `apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`, and `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php` + +### Implementation for User Story 1 + +- [X] T010 [US1] Replace raw type comparisons and duplicate option logic in `apps/platform/app/Filament/Resources/OperationRunResource.php` and `apps/platform/app/Support/Filament/FilterOptionCatalog.php` +- [X] T011 [P] [US1] Align shared labels and related-run links with canonical resolution in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/References/Resolvers/OperationRunReferenceResolver.php`, and `apps/platform/app/Support/OperationRunLinks.php` +- [X] T012 [P] [US1] Canonicalize audit-adjacent operation metadata in `apps/platform/app/Services/Audit/AuditEventBuilder.php` and `apps/platform/app/Services/OperationRunService.php` +- [X] T013 [P] [US1] Canonicalize monitoring and summary payloads in `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php`, `apps/platform/app/Services/Evidence/Sources/OperationsSummarySource.php`, and `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php` +- [X] T014 [US1] Run the relevant quickstart verification blocks for US1 from `specs/239-canonical-operation-type-source-of-truth/quickstart.md` (expanded narrow proving lane, representative operator-surface proof, and guardrail proof) against `apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php`, `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php`, `apps/platform/tests/Feature/Monitoring/AuditCoverageOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`, `apps/platform/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php`, `apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`, `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, and `apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php` + +**Checkpoint**: User Story 1 is independently deliverable as the core monitoring and read-model parity slice. + +--- + +## Phase 4: User Story 2 - Resume Onboarding Without Rewriting Legacy Drift (Priority: P1) + +**Goal**: Make onboarding bootstrap resume, save, and start flows normalize legacy selections to canonical dotted operation codes without rewriting the platform back to raw aliases. + +**Independent Test**: Resume an onboarding draft with legacy bootstrap selections, save it again, and start bootstrap actions to verify canonical operation codes are persisted and handed to the shared start UX path. + +### Tests for User Story 2 + +- [X] T015 [P] [US2] Extend onboarding draft canonicalization coverage in `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php` and `apps/platform/tests/Unit/Onboarding/OnboardingDraftResolverTest.php` +- [X] T016 [P] [US2] Extend onboarding provider-start and authorization-safe bootstrap coverage in `apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php` and `apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php` + +### Implementation for User Story 2 + +- [X] T017 [US2] Normalize `bootstrap_operation_types` and `started_operation_type` on load, save, resume, and start in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php` +- [X] T018 [US2] Keep resumed consent and callback flows writing canonical bootstrap state in `apps/platform/app/Http/Controllers/AdminConsentCallbackController.php` and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [X] T019 [US2] Align onboarding audit metadata and shared start handoff with canonical operation codes in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `apps/platform/app/Services/Audit/AuditEventBuilder.php` +- [X] T020 [US2] Run the relevant quickstart verification blocks for US2 from `specs/239-canonical-operation-type-source-of-truth/quickstart.md` (expanded narrow proving lane plus representative operator-surface proof) against `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php`, `apps/platform/tests/Unit/Onboarding/OnboardingDraftResolverTest.php`, `apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php`, and `apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php` + +**Checkpoint**: User Stories 1 and 2 now expose the same canonical operation identity across monitoring and onboarding bootstrap state. + +--- + +## Phase 5: User Story 3 - Extend Operation Families Without Creating Peer Truths (Priority: P2) + +**Goal**: Harden provider-start and representative write paths so future work cannot silently reintroduce raw alias writers or alternate registry truth. + +**Independent Test**: Run focused registry, start-gate, and architecture guard coverage and verify raw alias write-time values fail while historical read-side compatibility remains explicitly bounded. + +### Tests for User Story 3 + +- [X] T021 [P] [US3] Add provider registry and start-gate canonical contract coverage in `apps/platform/tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php` and `apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php` +- [X] T022 [P] [US3] Extend raw-alias reintroduction guard coverage in `apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php` and `apps/platform/tests/Unit/Support/OperationRunTypeCanonicalContractTest.php` + +### Implementation for User Story 3 + +- [X] T023 [US3] Replace legacy registry `operation_type` values with canonical dotted codes and bounded read-side-only semantics in `apps/platform/app/Services/Providers/ProviderOperationRegistry.php` and `apps/platform/app/Services/Providers/ProviderOperationStartGate.php` +- [X] T024 [US3] Converge representative `OperationRun` writers on canonical dotted types in `apps/platform/app/Services/Inventory/InventorySyncService.php`, `apps/platform/app/Services/BackupScheduling/BackupScheduleDispatcher.php`, `apps/platform/app/Services/ReviewPackService.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewService.php` +- [X] T025 [US3] Document and enforce the remaining read-side compatibility boundary in `apps/platform/app/Support/OperationCatalog.php` and `specs/239-canonical-operation-type-source-of-truth/contracts/canonical-operation-type-source-of-truth.logical.openapi.yaml` +- [X] T026 [US3] Run the relevant quickstart verification blocks for US3 from `specs/239-canonical-operation-type-source-of-truth/quickstart.md` (expanded narrow proving lane plus guardrail proof) against `apps/platform/tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php`, `apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php`, `apps/platform/tests/Unit/Support/OperationRunTypeCanonicalContractTest.php`, and `apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php` + +**Checkpoint**: All user stories are independently functional, and representative write paths are locked behind focused canonical-operation guardrails. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Refresh implementation notes, run final narrow validation, and close out the bounded compatibility seam explicitly. + +- [X] T027 [P] Refresh implementation notes, logical contract wording, and validation commands in `specs/239-canonical-operation-type-source-of-truth/spec.md`, `specs/239-canonical-operation-type-source-of-truth/plan.md`, `specs/239-canonical-operation-type-source-of-truth/quickstart.md`, and `specs/239-canonical-operation-type-source-of-truth/contracts/canonical-operation-type-source-of-truth.logical.openapi.yaml` +- [X] T028 [P] Run formatting for touched files in `apps/platform/app/Support/`, `apps/platform/app/Services/`, `apps/platform/app/Filament/Pages/Workspaces/`, `apps/platform/app/Filament/Resources/`, `apps/platform/app/Http/Controllers/`, and `apps/platform/tests/` +- [X] T029 Run the full quickstart verification sequence from `specs/239-canonical-operation-type-source-of-truth/quickstart.md` against `apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php`, `apps/platform/tests/Unit/Support/OperationRunTypeCanonicalContractTest.php`, `apps/platform/tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php`, `apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php`, `apps/platform/tests/Unit/Onboarding/OnboardingDraftResolverTest.php`, `apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php`, `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php`, `apps/platform/tests/Feature/Monitoring/AuditCoverageOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`, `apps/platform/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php`, `apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`, `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php`, `apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php`, `apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php`, and `apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php` +- [X] T030 Record the guardrail close-out, `document-in-feature` disposition for the bounded read-side seam, and any deferred `follow-up-spec` boundary in `specs/239-canonical-operation-type-source-of-truth/plan.md` and `specs/239-canonical-operation-type-source-of-truth/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies; can start immediately. +- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user stories. +- **User Story 1 (Phase 3)**: Depends on Foundational completion. +- **User Story 2 (Phase 4)**: Depends on Foundational completion and should coordinate with User Story 1 where `apps/platform/app/Services/Audit/AuditEventBuilder.php` is shared. +- **User Story 3 (Phase 5)**: Depends on Foundational completion and should follow User Story 2 because provider-start hardening is the narrowest proof once onboarding handoff already emits canonical codes. +- **Polish (Phase 6)**: Depends on all desired user stories being complete. + +### User Story Dependencies + +- **User Story 1 (P1)**: This is the demonstration MVP for operator-visible monitoring parity. +- **User Story 2 (P1)**: Conceptually independent after Phase 2, but it shares `apps/platform/app/Services/Audit/AuditEventBuilder.php` with User Story 1 and should reuse the same canonical metadata truth. +- **User Story 3 (P2)**: Depends on the foundational contract and should harden provider-start and representative writers only after User Story 2 proves onboarding now hands off canonical codes. + +### Within Each User Story + +- Tests should be written and fail before the corresponding implementation tasks. +- Phase 2 must settle the canonical dotted contract before any read-model, onboarding, or provider-start adoption task lands. +- Serialize edits in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` across T017, T018, and T019. +- Serialize edits in `apps/platform/app/Services/Audit/AuditEventBuilder.php` across T012 and T019. +- Finish each story’s validation task before moving to the next priority when working sequentially. + +### Parallel Opportunities + +- **Setup**: T002 and T003 can run in parallel. +- **Foundational**: T004 and T005 can run in parallel before T006; T007 follows T006. +- **US1 tests**: T008 and T009 can run in parallel. +- **US1 implementation**: T011, T012, and T013 can run in parallel after T010 settles the primary filter/read-model behavior. +- **US2 tests**: T015 and T016 can run in parallel. +- **US3 tests**: T021 and T022 can run in parallel. +- **Polish**: T027 and T028 can run in parallel before T029 and T030. + +--- + +## Parallel Example: User Story 1 + +```bash +# Run US1 proof tasks in parallel: +T008 apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php +T009 apps/platform/tests/Feature/Monitoring/AuditCoverageOperationsTest.php and apps/platform/tests/Feature/OpsUx/UnknownOperationTypeLabelTest.php + +# Then split non-overlapping implementation follow-up: +T011 apps/platform/app/Support/OpsUx/OperationUxPresenter.php, apps/platform/app/Support/References/Resolvers/OperationRunReferenceResolver.php, and apps/platform/app/Support/OperationRunLinks.php +T013 apps/platform/app/Services/SystemConsole/OperationRunTriageService.php, apps/platform/app/Services/Evidence/Sources/OperationsSummarySource.php, and apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php +``` + +--- + +## Parallel Example: User Story 2 + +```bash +# Run US2 proof tasks in parallel: +T015 apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php and apps/platform/tests/Unit/Onboarding/OnboardingDraftResolverTest.php +T016 apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php and apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php +``` + +--- + +## Parallel Example: User Story 3 + +```bash +# Run US3 guard tasks in parallel: +T021 apps/platform/tests/Unit/Providers/ProviderOperationRegistryCanonicalTypeTest.php and apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php +T022 apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php and apps/platform/tests/Unit/Support/OperationRunTypeCanonicalContractTest.php +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Demonstration) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Stop and validate with T014. +5. Review whether operations filters, labels, references, and audit-adjacent summaries now teach one canonical `operation_type` truth. + +Even if User Story 1 is demoable on its own, User Stories 2 and 3 should land before merge because the active write surfaces and anti-drift guardrails are part of the accepted scope. + +### Incremental Delivery + +1. Setup and Foundational establish one canonical dotted contract and bounded read-side seam. +2. Add User Story 1 and validate monitoring/filter/read-model parity. +3. Add User Story 2 and validate onboarding bootstrap lifecycle and canonical start handoff. +4. Add User Story 3 and validate provider-start truth plus representative writer guardrails. +5. Finish with formatting, final validation, and explicit close-out of the remaining compatibility seam. + +### Parallel Team Strategy + +With multiple developers: + +1. Complete Setup and Foundational together. +2. After Phase 2: + - Developer A: User Story 1 read-model parity in `apps/platform/app/Filament/Resources/OperationRunResource.php`, `apps/platform/app/Support/Filament/FilterOptionCatalog.php`, and the shared presenter/reference files. + - Developer B: User Story 2 onboarding bootstrap normalization in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php`. + - Developer C: User Story 3 provider-start and representative write-path hardening in `apps/platform/app/Services/Providers/ProviderOperationRegistry.php`, `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, and the representative writer services. +3. Serialize edits in `apps/platform/app/Services/Audit/AuditEventBuilder.php` and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` because multiple story tasks touch those files. + +### Suggested MVP Scope + +The narrowest demonstration slice is Phase 1 through Phase 3. The narrowest merge-ready slice is Phase 1 through Phase 5, with Phase 6 reserved for close-out and final proof. + +--- + +## Notes + +- `[P]` marks tasks that can run in parallel once prerequisites are satisfied and files do not overlap. +- `[US1]`, `[US2]`, and `[US3]` map directly to the user stories in `spec.md`. +- Keep this feature strictly scoped to canonical `operation_type` truth. Do not widen into broader naming cleanup, governed-subject taxonomy work, monitoring IA redesign, or a generic compatibility framework. +- The only allowed compatibility seam is read-side resolution for historical `operation_runs.type` rows and persisted onboarding draft state already covered in the plan and logical contract. -- 2.45.2 From ab6eccaf402b0ea08f3ab06bf89c45278cf3fcf4 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sat, 25 Apr 2026 21:17:31 +0000 Subject: [PATCH 14/36] feat: add onboarding readiness workflow (#277) ## Summary - add derived onboarding readiness to the managed tenant onboarding workflow and multi-draft picker - keep provider-specific permission diagnostics secondary while preserving canonical `Open operation` and existing onboarding action semantics - add spec-kit artifacts for `240-tenant-onboarding-readiness` and align roadmap/spec-candidate planning notes - unify the required-permissions empty state copy to English ## Validation - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions/RequiredPermissionsEmptyStateTest.php` - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - browser smoke exercised the onboarding picker, route-bound mismatch readiness state, canonical `Open operation` path, and local fixture cleanup ## Notes - branch includes the generated spec artifacts under `specs/240-tenant-onboarding-readiness/` - temporary browser smoke tenants/drafts/runs were cleaned from the local environment after validation Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/277 --- .github/agents/copilot-instructions.md | 4 +- .../ManagedTenantOnboardingWizard.php | 838 ++++++++++++++++++ .../tenant-required-permissions.blade.php | 4 +- .../ManagedTenantOnboardingWizardTest.php | 493 +++++++++++ .../OnboardingDraftAuthorizationTest.php | 112 +++ .../Onboarding/OnboardingDraftPickerTest.php | 306 +++++++ .../RequiredPermissionsEmptyStateTest.php | 2 +- .../TenantOnboardingSessionPolicyTest.php | 35 + ...TenantRequiredPermissionsFreshnessTest.php | 16 + docs/product/roadmap.md | 59 +- docs/product/spec-candidates.md | 103 ++- .../checklists/requirements.md | 37 + .../onboarding-readiness.openapi.yaml | 266 ++++++ .../data-model.md | 140 +++ specs/240-tenant-onboarding-readiness/plan.md | 196 ++++ .../quickstart.md | 39 + .../research.md | 72 ++ specs/240-tenant-onboarding-readiness/spec.md | 297 +++++++ .../240-tenant-onboarding-readiness/tasks.md | 194 ++++ 19 files changed, 3185 insertions(+), 28 deletions(-) create mode 100644 specs/240-tenant-onboarding-readiness/checklists/requirements.md create mode 100644 specs/240-tenant-onboarding-readiness/contracts/onboarding-readiness.openapi.yaml create mode 100644 specs/240-tenant-onboarding-readiness/data-model.md create mode 100644 specs/240-tenant-onboarding-readiness/plan.md create mode 100644 specs/240-tenant-onboarding-readiness/quickstart.md create mode 100644 specs/240-tenant-onboarding-readiness/research.md create mode 100644 specs/240-tenant-onboarding-readiness/spec.md create mode 100644 specs/240-tenant-onboarding-readiness/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index a5e4affa..bac85be0 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -256,6 +256,8 @@ ## Active Technologies - Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned (238-provider-identity-target-scope) - PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4 (239-canonical-operation-type-source-of-truth) - PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables (239-canonical-operation-type-source-of-truth) +- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers (240-tenant-onboarding-readiness) +- PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned (240-tenant-onboarding-readiness) - PHP 8.4.15 (feat/005-bulk-operations) @@ -290,9 +292,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 240-tenant-onboarding-readiness: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers - 239-canonical-operation-type-source-of-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4 - 238-provider-identity-target-scope: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4 -- 237-provider-boundary-hardening: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4 ### Pre-production compatibility check diff --git a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index 2915116d..c482e536 100644 --- a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -24,6 +24,7 @@ use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Auth\TenantMembershipManager; use App\Services\Intune\AuditLogger; +use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder; use App\Services\Onboarding\OnboardingDraftMutationService; use App\Services\Onboarding\OnboardingDraftResolver; use App\Services\Onboarding\OnboardingDraftStageResolver; @@ -38,6 +39,7 @@ use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeDomain; +use App\Support\Links\RequiredPermissionsLinks; use App\Support\Livewire\TrustedState\TrustedStateResolver; use App\Support\Onboarding\OnboardingCheckpoint; use App\Support\Onboarding\OnboardingDraftStage; @@ -314,6 +316,7 @@ public function content(Schema $schema): Schema SchemaView::make('filament.schemas.components.managed-tenant-onboarding-checkpoint-poll') ->visible(fn (): bool => $this->shouldPollCheckpointLifecycle()), ...$this->resumeContextSchema(), + ...$this->routeBoundReadinessSchema(), Wizard::make([ Step::make('Identify managed tenant') ->description('Create or resume a managed tenant in this workspace.') @@ -926,6 +929,7 @@ private function draftPickerSchema(): array Text::make('Draft age') ->color('gray'), Text::make(fn (): string => $draft->created_at?->diffForHumans() ?? '—'), + ...$this->draftCompactReadinessSchema($draft), SchemaActions::make([ Action::make('resume_draft_'.$draft->getKey()) ->label($this->resumeOnboardingActionLabel()) @@ -978,6 +982,840 @@ private function resumeContextSchema(): array ]; } + /** + * @return array + */ + private function routeBoundReadinessSchema(): array + { + $draft = $this->currentOnboardingSessionRecord(); + + if (! $draft instanceof TenantOnboardingSession) { + return []; + } + + $payload = $this->onboardingReadinessPayload($draft); + + $schema = [ + Section::make('Onboarding readiness') + ->description($payload['blocker']['operator_summary']) + ->compact() + ->columns(2) + ->schema([ + Text::make('Current checkpoint') + ->color('gray'), + Text::make($payload['checkpoint']['current_checkpoint_label'] ?? '—') + ->badge() + ->color('info'), + Text::make('Lifecycle') + ->color('gray'), + Text::make($payload['checkpoint']['lifecycle_label']) + ->badge() + ->color($this->readinessLifecycleColor($payload['checkpoint']['lifecycle_state'])), + Text::make('Provider connection') + ->color('gray'), + Text::make($this->readinessProviderLine($payload)) + ->badge() + ->color($this->readinessSummaryColor($payload)), + Text::make('Freshness') + ->color('gray'), + Text::make($payload['freshness']['note']), + Text::make('Primary next action') + ->color('gray'), + Text::make($payload['next_action']['label']) + ->badge() + ->color($this->readinessNextActionColor($payload['next_action']['kind'])), + ]), + ]; + + $permissionDiagnostics = $this->readinessPermissionDiagnosticsSchema($payload, 'route_bound_readiness'); + $supportingEvidence = $this->readinessSupportingEvidenceSchema($payload, 'route_bound_readiness'); + + return array_merge($schema, $permissionDiagnostics, $supportingEvidence); + } + + /** + * @return array + */ + private function draftCompactReadinessSchema(TenantOnboardingSession $draft): array + { + $payload = $this->onboardingReadinessPayload($draft); + + return [ + Text::make('Compact readiness') + ->color('gray'), + Text::make($payload['blocker']['operator_summary']) + ->badge() + ->color($this->readinessSummaryColor($payload)), + Text::make('Freshness') + ->color('gray'), + Text::make($payload['freshness']['note']), + Text::make('Next action') + ->color('gray'), + Text::make($payload['next_action']['label']) + ->badge() + ->color($this->readinessNextActionColor($payload['next_action']['kind'])), + ]; + } + + /** + * @param array $payload + * @return array + */ + private function readinessSupportingEvidenceSchema(array $payload, string $keyPrefix): array + { + $links = is_array($payload['supporting_links'] ?? null) ? $payload['supporting_links'] : []; + + if ($links === []) { + return []; + } + + $actions = []; + + foreach (array_values($links) as $index => $link) { + if (! is_array($link)) { + continue; + } + + $label = is_string($link['label'] ?? null) ? $link['label'] : OperationRunLinks::openLabel(); + $url = is_string($link['url'] ?? null) ? $link['url'] : null; + + if ($url === null || $url === '') { + continue; + } + + $actions[] = Action::make($keyPrefix.'_supporting_operation_'.$index) + ->label($label) + ->color('gray') + ->url($url); + } + + if ($actions === []) { + return []; + } + + return [ + Section::make('Supporting evidence') + ->description('Open canonical operation detail when deeper diagnostics are needed.') + ->compact() + ->schema([ + SchemaActions::make($actions)->key($keyPrefix.'_supporting_evidence_actions'), + ]), + ]; + } + + /** + * @param array $payload + * @return array + */ + private function readinessPermissionDiagnosticsSchema(array $payload, string $keyPrefix): array + { + $permissions = is_array($payload['permissions'] ?? null) ? $payload['permissions'] : null; + + if ($permissions === null) { + return []; + } + + $counts = is_array($permissions['counts'] ?? null) ? $permissions['counts'] : []; + $missingApplication = (int) ($counts['missing_application'] ?? 0); + $missingDelegated = (int) ($counts['missing_delegated'] ?? 0); + $errors = (int) ($counts['error'] ?? 0); + $assist = is_array($payload['verification_assist'] ?? null) ? $payload['verification_assist'] : []; + $isVisible = (bool) ($assist['is_visible'] ?? false); + + if ($missingApplication + $missingDelegated + $errors === 0 && ! $isVisible) { + return []; + } + + $schema = [ + Text::make('Missing application permissions') + ->color('gray'), + Text::make((string) $missingApplication) + ->badge() + ->color($missingApplication > 0 ? 'warning' : 'success'), + Text::make('Missing delegated permissions') + ->color('gray'), + Text::make((string) $missingDelegated) + ->badge() + ->color($missingDelegated > 0 ? 'warning' : 'success'), + ]; + + $applicationLine = $this->readinessMissingPermissionLine($permissions, 'application'); + $delegatedLine = $this->readinessMissingPermissionLine($permissions, 'delegated'); + + if ($applicationLine !== null) { + $schema[] = Text::make('Application permission detail')->color('gray'); + $schema[] = Text::make($applicationLine); + } + + if ($delegatedLine !== null) { + $schema[] = Text::make('Delegated permission detail')->color('gray'); + $schema[] = Text::make($delegatedLine); + } + + $url = is_string($permissions['required_permissions_url'] ?? null) ? $permissions['required_permissions_url'] : null; + + if ($url !== null && $url !== '') { + $schema[] = SchemaActions::make([ + Action::make($keyPrefix.'_review_permissions') + ->label('Review permissions') + ->color('gray') + ->url($url), + ])->key($keyPrefix.'_permission_diagnostics_actions'); + } + + return [ + Section::make('Permission diagnostics') + ->description('Provider-owned permission detail stays secondary to readiness while still giving exact remediation context.') + ->compact() + ->columns(2) + ->schema($schema), + ]; + } + + /** + * @return array{ + * draft: array{id: int, tenant_name: string, stage_label: string, draft_status_label: string, started_by: string, updated_by: string, last_updated_human: string}, + * checkpoint: array{current_checkpoint: string|null, current_checkpoint_label: string|null, last_completed_checkpoint: string|null, lifecycle_state: string, lifecycle_label: string}, + * provider_summary: array|null, + * verification: array{status: string, status_label: string, run_id: int|null, run_url: string|null, is_active: bool, matches_selected_connection: bool|null, overall: string|null}, + * verification_assist: array{is_visible: bool, reason: string}, + * permissions: array{overall: string|null, counts: array, freshness: array{last_refreshed_at: string|null, is_stale: bool}, missing_permissions: array{application: list, delegated: list}, required_permissions_url: string|null}|null, + * freshness: array{connection_recently_updated: bool, verification_mismatch: bool, permission_last_refreshed_at: string|null, permission_data_is_stale: bool, note: string}, + * blocker: array{reason_code: string|null, blocking_reason_code: string|null, operator_summary: string}, + * next_action: array{label: string, kind: string, url: string|null, action_name: string|null, required_capability: string|null}, + * supporting_links: list + * } + */ + private function onboardingReadinessPayload(TenantOnboardingSession $draft): array + { + $snapshot = $this->lifecycleService()->snapshot($draft); + $stage = app(OnboardingDraftStageResolver::class)->resolve($draft); + $lifecycleState = $snapshot['lifecycle_state'] instanceof OnboardingLifecycleState + ? $snapshot['lifecycle_state'] + : OnboardingLifecycleState::Draft; + $currentCheckpoint = $snapshot['current_checkpoint'] instanceof OnboardingCheckpoint + ? $snapshot['current_checkpoint'] + : null; + $lastCompletedCheckpoint = $snapshot['last_completed_checkpoint'] instanceof OnboardingCheckpoint + ? $snapshot['last_completed_checkpoint'] + : null; + + $tenant = $draft->tenant instanceof Tenant ? $draft->tenant : null; + $providerConnection = $this->readinessProviderConnection($draft); + $selectedProviderConnectionId = $providerConnection instanceof ProviderConnection + ? (int) $providerConnection->getKey() + : null; + + $verificationRun = $this->lifecycleService()->verificationRun($draft); + $verificationStatus = $this->lifecycleService()->verificationStatus( + draft: $draft, + selectedProviderConnectionId: $selectedProviderConnectionId, + run: $verificationRun, + ); + $verificationMatchesSelectedConnection = $verificationRun instanceof OperationRun + ? $this->readinessRunMatchesSelectedConnection($verificationRun, $selectedProviderConnectionId) + : null; + $permissions = $tenant instanceof Tenant ? $this->readinessPermissionOverview($tenant) : null; + $verificationReport = $verificationRun instanceof OperationRun ? VerificationReportViewer::report($verificationRun) : null; + $verificationReport = is_array($verificationReport) ? $verificationReport : null; + $permissionFreshness = is_array($permissions['freshness'] ?? null) ? $permissions['freshness'] : [ + 'last_refreshed_at' => null, + 'is_stale' => true, + ]; + + $connectionRecentlyUpdated = $this->readinessConnectionRecentlyUpdated($draft); + $verificationMismatch = $verificationRun instanceof OperationRun && $verificationMatchesSelectedConnection === false; + $supportingLinks = $this->readinessSupportingLinks($verificationRun, $draft, $selectedProviderConnectionId); + $readinessSummary = $this->readinessSummaryText( + draft: $draft, + lifecycleState: $lifecycleState, + providerConnection: $providerConnection, + verificationRun: $verificationRun, + verificationStatus: $verificationStatus, + permissions: $permissions, + connectionRecentlyUpdated: $connectionRecentlyUpdated, + verificationMismatch: $verificationMismatch, + ); + + return [ + 'draft' => [ + 'id' => (int) $draft->getKey(), + 'tenant_name' => $this->draftTitle($draft), + 'stage_label' => $stage->label(), + 'draft_status_label' => $draft->status()->label(), + 'started_by' => $draft->startedByUser?->name ?? 'Unknown', + 'updated_by' => $draft->updatedByUser?->name ?? 'Unknown', + 'last_updated_human' => $draft->updated_at?->diffForHumans() ?? '—', + ], + 'checkpoint' => [ + 'current_checkpoint' => $currentCheckpoint?->value, + 'current_checkpoint_label' => $currentCheckpoint?->label(), + 'last_completed_checkpoint' => $lastCompletedCheckpoint?->label(), + 'lifecycle_state' => $lifecycleState->value, + 'lifecycle_label' => $lifecycleState->label(), + ], + 'provider_summary' => $this->readinessProviderSummary($providerConnection), + 'verification' => [ + 'status' => $verificationStatus, + 'status_label' => BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, $verificationStatus)->label, + 'run_id' => $verificationRun instanceof OperationRun ? (int) $verificationRun->getKey() : null, + 'run_url' => $verificationRun instanceof OperationRun && $this->canInspectOperationRun($verificationRun) + ? OperationRunLinks::tenantlessView($verificationRun) + : null, + 'is_active' => $verificationRun instanceof OperationRun && $verificationRun->status !== OperationRunStatus::Completed->value, + 'matches_selected_connection' => $verificationMatchesSelectedConnection, + 'overall' => $verificationRun instanceof OperationRun + ? $this->readinessVerificationOverall($verificationRun, $verificationReport) + : null, + ], + 'verification_assist' => $tenant instanceof Tenant && $verificationReport !== null + ? app(VerificationAssistViewModelBuilder::class)->visibility($tenant, $verificationReport) + : $this->hiddenVerificationAssistVisibility(), + 'permissions' => $permissions, + 'freshness' => [ + 'connection_recently_updated' => $connectionRecentlyUpdated, + 'verification_mismatch' => $verificationMismatch, + 'permission_last_refreshed_at' => $permissionFreshness['last_refreshed_at'] ?? null, + 'permission_data_is_stale' => (bool) ($permissionFreshness['is_stale'] ?? true), + 'note' => $this->readinessFreshnessNote( + verificationRun: $verificationRun, + verificationStatus: $verificationStatus, + connectionRecentlyUpdated: $connectionRecentlyUpdated, + verificationMismatch: $verificationMismatch, + permissionFreshness: $permissionFreshness, + ), + ], + 'blocker' => [ + 'reason_code' => is_string($snapshot['reason_code'] ?? null) ? $snapshot['reason_code'] : null, + 'blocking_reason_code' => is_string($snapshot['blocking_reason_code'] ?? null) ? $snapshot['blocking_reason_code'] : null, + 'operator_summary' => $readinessSummary, + ], + 'next_action' => $this->readinessNextAction( + draft: $draft, + lifecycleState: $lifecycleState, + providerConnection: $providerConnection, + verificationRun: $verificationRun, + verificationStatus: $verificationStatus, + permissions: $permissions, + connectionRecentlyUpdated: $connectionRecentlyUpdated, + verificationMismatch: $verificationMismatch, + supportingLinks: $supportingLinks, + ), + 'supporting_links' => $supportingLinks, + ]; + } + + /** + * @param array $payload + */ + private function readinessProviderLine(array $payload): string + { + $summary = is_array($payload['provider_summary'] ?? null) ? $payload['provider_summary'] : null; + + if ($summary === null) { + return 'Not connected'; + } + + $readiness = is_string($summary['readiness_summary'] ?? null) ? $summary['readiness_summary'] : 'Needs review'; + $targetScope = is_string($summary['target_scope_summary'] ?? null) ? $summary['target_scope_summary'] : null; + + return $targetScope === null || $targetScope === '' + ? $readiness + : sprintf('%s - %s', $readiness, $targetScope); + } + + /** + * @param array $payload + */ + private function readinessSummaryColor(array $payload): string + { + $summary = strtolower((string) ($payload['blocker']['operator_summary'] ?? '')); + + if (str_contains($summary, 'ready') || str_contains($summary, 'completed')) { + return 'success'; + } + + if (str_contains($summary, 'running') || str_contains($summary, 'continue')) { + return 'info'; + } + + if (str_contains($summary, 'required') || str_contains($summary, 'disabled') || str_contains($summary, 'attention') || str_contains($summary, 'refresh')) { + return 'warning'; + } + + return 'gray'; + } + + private function readinessLifecycleColor(string $state): string + { + return match ($state) { + OnboardingLifecycleState::ReadyForActivation->value, + OnboardingLifecycleState::Completed->value => 'success', + OnboardingLifecycleState::Verifying->value, + OnboardingLifecycleState::Bootstrapping->value => 'info', + OnboardingLifecycleState::ActionRequired->value => 'warning', + OnboardingLifecycleState::Cancelled->value => 'danger', + default => 'gray', + }; + } + + private function readinessNextActionColor(string $kind): string + { + return match ($kind) { + 'complete_onboarding' => 'success', + 'grant_consent', + 'review_provider_connection', + 'review_permissions', + 'rerun_verification', + 'review_bootstrap' => 'warning', + 'start_verification', + 'open_operation' => 'info', + default => 'gray', + }; + } + + private function readinessProviderConnection(TenantOnboardingSession $draft): ?ProviderConnection + { + $state = is_array($draft->state) ? $draft->state : []; + $providerConnectionId = $this->normalizeReadinessInteger( + $state['provider_connection_id'] ?? $state['selected_provider_connection_id'] ?? null, + ); + + if ($providerConnectionId === null || $draft->tenant_id === null) { + return null; + } + + return ProviderConnection::query() + ->whereKey($providerConnectionId) + ->where('workspace_id', (int) $draft->workspace_id) + ->where('tenant_id', (int) $draft->tenant_id) + ->first(); + } + + /** + * @return array|null + */ + private function readinessProviderSummary(?ProviderConnection $connection): ?array + { + if (! $connection instanceof ProviderConnection) { + return null; + } + + try { + $summary = ProviderConnectionSurfaceSummary::forConnection($connection); + } catch (InvalidArgumentException) { + return [ + 'provider' => (string) $connection->provider, + 'target_scope' => [], + 'consent_state' => (string) $connection->consent_status, + 'verification_state' => (string) $connection->verification_status, + 'readiness_summary' => 'Target scope needs review', + 'target_scope_summary' => 'Target scope needs review', + 'contextual_identity_line' => null, + 'is_enabled' => (bool) $connection->is_enabled, + ]; + } + + return array_merge($summary->toArray(), [ + 'target_scope_summary' => $summary->targetScopeSummary(), + 'contextual_identity_line' => $summary->contextualIdentityLine(), + 'is_enabled' => (bool) $connection->is_enabled, + ]); + } + + /** + * @return array{overall: string|null, counts: array, freshness: array{last_refreshed_at: string|null, is_stale: bool}, missing_permissions: array{application: list, delegated: list}, required_permissions_url: string|null} + */ + private function readinessPermissionOverview(Tenant $tenant): array + { + $viewModel = app(TenantRequiredPermissionsViewModelBuilder::class)->build($tenant, [ + 'status' => 'all', + 'type' => 'all', + 'features' => [], + 'search' => '', + ]); + + $overview = is_array($viewModel['overview'] ?? null) ? $viewModel['overview'] : []; + $counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : []; + $freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : []; + $permissions = is_array($viewModel['permissions'] ?? null) ? $viewModel['permissions'] : []; + + return [ + 'overall' => is_string($overview['overall'] ?? null) ? $overview['overall'] : null, + 'counts' => [ + 'missing_application' => max(0, (int) ($counts['missing_application'] ?? 0)), + 'missing_delegated' => max(0, (int) ($counts['missing_delegated'] ?? 0)), + 'present' => max(0, (int) ($counts['present'] ?? 0)), + 'error' => max(0, (int) ($counts['error'] ?? 0)), + ], + 'freshness' => [ + 'last_refreshed_at' => is_string($freshness['last_refreshed_at'] ?? null) ? $freshness['last_refreshed_at'] : null, + 'is_stale' => (bool) ($freshness['is_stale'] ?? true), + ], + 'missing_permissions' => [ + 'application' => $this->readinessMissingPermissionKeys($permissions, 'application'), + 'delegated' => $this->readinessMissingPermissionKeys($permissions, 'delegated'), + ], + 'required_permissions_url' => RequiredPermissionsLinks::requiredPermissions($tenant), + ]; + } + + /** + * @param array $permissions + * @param 'application'|'delegated' $type + * @return list + */ + private function readinessMissingPermissionKeys(array $permissions, string $type): array + { + return collect($permissions) + ->filter(static fn (mixed $row): bool => is_array($row) + && ($row['status'] ?? null) === 'missing' + && ($row['type'] ?? null) === $type + && is_string($row['key'] ?? null) + && trim((string) $row['key']) !== '') + ->map(static fn (array $row): string => trim((string) $row['key'])) + ->values() + ->all(); + } + + /** + * @param array $permissions + * @param 'application'|'delegated' $type + */ + private function readinessMissingPermissionLine(array $permissions, string $type): ?string + { + $missing = is_array($permissions['missing_permissions'][$type] ?? null) + ? $permissions['missing_permissions'][$type] + : []; + + $missing = array_values(array_filter($missing, static fn (mixed $value): bool => is_string($value) && trim($value) !== '')); + + if ($missing === []) { + return null; + } + + return implode(', ', array_slice($missing, 0, 5)); + } + + /** + * @param array{last_refreshed_at?: string|null, is_stale?: bool} $permissionFreshness + */ + private function readinessFreshnessNote( + ?OperationRun $verificationRun, + string $verificationStatus, + bool $connectionRecentlyUpdated, + bool $verificationMismatch, + array $permissionFreshness, + ): string { + if (! $verificationRun instanceof OperationRun) { + return 'Verification has not run yet.'; + } + + if ($connectionRecentlyUpdated) { + return 'Provider connection changed; rerun verification to refresh readiness.'; + } + + if ($verificationMismatch) { + return 'Verification evidence belongs to a different provider connection.'; + } + + if ((bool) ($permissionFreshness['is_stale'] ?? true)) { + return is_string($permissionFreshness['last_refreshed_at'] ?? null) + ? 'Permission data is older than the 30-day freshness window.' + : 'Permission check has not run yet.'; + } + + if ($verificationStatus === 'in_progress') { + return 'Verification is running; refresh for the latest result.'; + } + + return 'Verification and permission evidence are current.'; + } + + /** + * @param array|null $permissions + */ + private function readinessSummaryText( + TenantOnboardingSession $draft, + OnboardingLifecycleState $lifecycleState, + ?ProviderConnection $providerConnection, + ?OperationRun $verificationRun, + string $verificationStatus, + ?array $permissions, + bool $connectionRecentlyUpdated, + bool $verificationMismatch, + ): string { + if (! $this->readinessDraftHasTenantIdentity($draft)) { + return 'Tenant identity required'; + } + + if (! $providerConnection instanceof ProviderConnection) { + return 'Provider connection required'; + } + + if (! (bool) $providerConnection->is_enabled) { + return 'Provider connection disabled'; + } + + $consentState = $providerConnection->consent_status instanceof ProviderConsentStatus + ? $providerConnection->consent_status->value + : (string) $providerConnection->consent_status; + + if ($consentState !== ProviderConsentStatus::Granted->value) { + return match ($consentState) { + ProviderConsentStatus::Revoked->value => 'Provider consent revoked', + ProviderConsentStatus::Failed->value => 'Provider consent failed', + default => 'Provider consent required', + }; + } + + if ($connectionRecentlyUpdated || $verificationMismatch) { + return 'Verification needs refresh'; + } + + if (! $verificationRun instanceof OperationRun) { + return 'Verification has not run yet'; + } + + if ($verificationStatus === 'in_progress') { + return 'Verification running'; + } + + $permissionOverall = is_string($permissions['overall'] ?? null) ? $permissions['overall'] : null; + + if ($verificationStatus === 'blocked' || $permissionOverall === VerificationReportOverall::Blocked->value) { + return 'Permission or consent blocker needs attention'; + } + + if ($permissionOverall === VerificationReportOverall::NeedsAttention->value || (bool) ($permissions['freshness']['is_stale'] ?? false)) { + return 'Readiness needs attention'; + } + + return match ($lifecycleState) { + OnboardingLifecycleState::Verifying => 'Verification running', + OnboardingLifecycleState::Bootstrapping => 'Bootstrap running', + OnboardingLifecycleState::ActionRequired => 'Onboarding needs attention', + OnboardingLifecycleState::ReadyForActivation => 'Ready for activation', + OnboardingLifecycleState::Completed => 'Completed', + OnboardingLifecycleState::Cancelled => 'Cancelled', + default => 'Continue onboarding', + }; + } + + /** + * @param array|null $permissions + * @param list $supportingLinks + * @return array{label: string, kind: string, url: string|null, action_name: string|null, required_capability: string|null} + */ + private function readinessNextAction( + TenantOnboardingSession $draft, + OnboardingLifecycleState $lifecycleState, + ?ProviderConnection $providerConnection, + ?OperationRun $verificationRun, + string $verificationStatus, + ?array $permissions, + bool $connectionRecentlyUpdated, + bool $verificationMismatch, + array $supportingLinks, + ): array { + if (! $this->readinessDraftHasTenantIdentity($draft)) { + return $this->readinessAction('Identify tenant', 'start_onboarding'); + } + + if (! $providerConnection instanceof ProviderConnection) { + return $this->readinessAction('Connect provider', 'resume_draft'); + } + + $consentState = $providerConnection->consent_status instanceof ProviderConsentStatus + ? $providerConnection->consent_status->value + : (string) $providerConnection->consent_status; + + if (! (bool) $providerConnection->is_enabled) { + return $this->readinessAction( + label: 'Review provider connection', + kind: 'review_provider_connection', + ); + } + + if ($consentState !== ProviderConsentStatus::Granted->value) { + return $this->readinessAction( + label: 'Grant consent', + kind: 'grant_consent', + url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($draft->tenant) : null, + ); + } + + $permissionOverall = is_string($permissions['overall'] ?? null) ? $permissions['overall'] : null; + + if ($permissionOverall === VerificationReportOverall::Blocked->value) { + return $this->readinessAction( + label: 'Review permissions', + kind: 'review_permissions', + url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::requiredPermissions($draft->tenant) : null, + ); + } + + if (! $verificationRun instanceof OperationRun) { + return $this->readinessAction( + label: 'Start verification', + kind: 'start_verification', + actionName: 'wizardStartVerification', + requiredCapability: Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START, + ); + } + + if ($connectionRecentlyUpdated || $verificationMismatch || (bool) ($permissions['freshness']['is_stale'] ?? false)) { + return $this->readinessAction( + label: 'Rerun verification', + kind: 'rerun_verification', + actionName: 'wizardStartVerification', + requiredCapability: Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START, + ); + } + + if ($verificationStatus === 'in_progress' && $supportingLinks !== []) { + return $this->readinessAction( + label: OperationRunLinks::openLabel(), + kind: 'open_operation', + url: $supportingLinks[0]['url'], + ); + } + + if ($lifecycleState === OnboardingLifecycleState::Bootstrapping || $lifecycleState === OnboardingLifecycleState::ActionRequired) { + return $this->readinessAction('Review bootstrap', 'review_bootstrap'); + } + + if ($lifecycleState === OnboardingLifecycleState::ReadyForActivation) { + return $this->readinessAction( + label: 'Complete onboarding', + kind: 'complete_onboarding', + actionName: 'wizardCompleteOnboarding', + requiredCapability: Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE, + ); + } + + return $this->readinessAction('Continue onboarding', 'resume_draft'); + } + + /** + * @return array{label: string, kind: string, url: string|null, action_name: string|null, required_capability: string|null} + */ + private function readinessAction( + string $label, + string $kind, + ?string $url = null, + ?string $actionName = null, + ?string $requiredCapability = null, + ): array { + return [ + 'label' => $label, + 'kind' => $kind, + 'url' => $url, + 'action_name' => $actionName, + 'required_capability' => $requiredCapability, + ]; + } + + /** + * @return list + */ + private function readinessSupportingLinks(?OperationRun $verificationRun, TenantOnboardingSession $draft, ?int $selectedProviderConnectionId): array + { + $links = []; + + if ($verificationRun instanceof OperationRun && $this->canInspectOperationRun($verificationRun)) { + $links[] = [ + 'label' => OperationRunLinks::openLabel(), + 'url' => OperationRunLinks::tenantlessView($verificationRun), + ]; + } + + foreach ($this->lifecycleService()->bootstrapRunSummaries($draft, $selectedProviderConnectionId) as $summary) { + $runId = $this->normalizeReadinessInteger($summary['run_id'] ?? null); + + if ($runId === null) { + continue; + } + + $run = OperationRun::query() + ->whereKey($runId) + ->where('workspace_id', (int) $draft->workspace_id) + ->first(); + + if (! $run instanceof OperationRun || ! $this->canInspectOperationRun($run)) { + continue; + } + + $links[] = [ + 'label' => OperationRunLinks::openLabel(), + 'url' => OperationRunLinks::tenantlessView($run), + ]; + } + + return array_values(array_unique($links, SORT_REGULAR)); + } + + private function readinessVerificationOverall(OperationRun $run, ?array $report = null): ?string + { + $report ??= VerificationReportViewer::report($run); + $summary = is_array($report['summary'] ?? null) ? $report['summary'] : []; + $overall = $summary['overall'] ?? null; + + return is_string($overall) && in_array($overall, VerificationReportOverall::values(), true) + ? $overall + : null; + } + + private function readinessRunMatchesSelectedConnection(OperationRun $run, ?int $selectedProviderConnectionId): bool + { + if ($selectedProviderConnectionId === null) { + return false; + } + + $context = is_array($run->context ?? null) ? $run->context : []; + $runProviderConnectionId = $this->normalizeReadinessInteger($context['provider_connection_id'] ?? null); + + return $runProviderConnectionId !== null && $runProviderConnectionId === $selectedProviderConnectionId; + } + + private function readinessConnectionRecentlyUpdated(TenantOnboardingSession $draft): bool + { + $state = is_array($draft->state) ? $draft->state : []; + + return (bool) ($state['connection_recently_updated'] ?? false); + } + + private function readinessDraftHasTenantIdentity(TenantOnboardingSession $draft): bool + { + if ($draft->tenant_id !== null) { + return true; + } + + $state = is_array($draft->state) ? $draft->state : []; + $entraTenantId = $state['entra_tenant_id'] ?? $draft->entra_tenant_id; + + return is_string($entraTenantId) && trim($entraTenantId) !== ''; + } + + private function normalizeReadinessInteger(mixed $value): ?int + { + if (is_int($value) && $value > 0) { + return $value; + } + + if (is_string($value) && ctype_digit(trim($value))) { + return (int) trim($value); + } + + if (is_numeric($value)) { + $normalized = (int) $value; + + return $normalized > 0 ? $normalized : null; + } + + return null; + } + /** * @return array */ diff --git a/apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php b/apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php index f26fb081..5f4342e0 100644 --- a/apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php +++ b/apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php @@ -136,9 +136,9 @@ @if (! $hasStoredPermissionData)
-
Keine Daten verfügbar
+
No data available
- Für diesen Tenant liegen noch keine gespeicherten Verifikationsdaten vor. + No stored verification data is available for this tenant. Start verification.
diff --git a/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php b/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php index 326b17c0..7dcd84f7 100644 --- a/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php +++ b/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php @@ -7,6 +7,7 @@ use App\Models\OperationRun; use App\Models\ProviderConnection; use App\Models\Tenant; +use App\Models\TenantPermission; use App\Models\TenantOnboardingSession; use App\Models\User; use App\Models\Workspace; @@ -14,12 +15,157 @@ use App\Support\Audit\AuditActionId; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\Providers\ProviderConsentStatus; +use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderConnectionType; use App\Support\Verification\VerificationReportWriter; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Testing\TestAction; use Livewire\Livewire; +function managedReadinessPermissionKeys(): array +{ + $configured = array_merge( + config('intune_permissions.permissions', []), + config('entra_permissions.permissions', []), + ); + + return array_values(array_filter(array_map(static function (mixed $permission): ?string { + if (! is_array($permission)) { + return null; + } + + $key = $permission['key'] ?? null; + + return is_string($key) && trim($key) !== '' ? trim($key) : null; + }, $configured))); +} + +function seedManagedReadinessPermissions(Tenant $tenant, ?int $staleDays = null, ?string $missingKey = null): ?string +{ + $keys = managedReadinessPermissionKeys(); + $missingKey ??= $keys[0] ?? null; + + foreach ($keys as $key) { + if ($missingKey !== null && $key === $missingKey) { + continue; + } + + TenantPermission::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'permission_key' => $key, + 'status' => 'granted', + 'details' => ['source' => 'readiness-test'], + 'last_checked_at' => $staleDays === null ? now() : now()->subDays($staleDays), + ]); + } + + return $missingKey; +} + +/** + * @return array{0: User, 1: TenantOnboardingSession, 2: ProviderConnection, 3: OperationRun|null, 4: string|null} + */ +function createManagedReadinessBlockerDraft(string $state): array +{ + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->onboarding()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => fake()->uuid(), + 'name' => 'Blocker Tenant '.str_replace('_', ' ', $state), + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + $connectionState = [ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'display_name' => 'Blocker connection', + 'is_default' => true, + ]; + + if ($state === 'missing_consent') { + $connectionState['consent_status'] = ProviderConsentStatus::Required->value; + } + + if ($state === 'revoked_consent') { + $connectionState['consent_status'] = ProviderConsentStatus::Revoked->value; + } + + if ($state === 'disabled_connection') { + $connectionState['is_enabled'] = false; + $connectionState['consent_status'] = ProviderConsentStatus::Granted->value; + } + + $connection = ProviderConnection::factory()->platform()->create($connectionState); + $run = null; + $missingKey = null; + + if ($state === 'blocked_verification' || $state === 'permission_gap') { + $connection->forceFill([ + 'is_enabled' => true, + 'consent_status' => ProviderConsentStatus::Granted->value, + ])->save(); + + $missingKey = seedManagedReadinessPermissions($tenant); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Blocked->value, + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_report' => VerificationReportWriter::build('provider.connection.check', [ + [ + 'key' => 'permissions.admin_consent', + 'title' => 'Required application permissions', + 'status' => 'fail', + 'severity' => 'critical', + 'blocking' => true, + 'reason_code' => ProviderReasonCodes::ProviderPermissionMissing, + 'message' => 'Missing required provider permissions.', + 'evidence' => [], + 'next_steps' => [], + ], + ]), + ], + ]); + } + + $draft = createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $tenant, + 'started_by' => $user, + 'updated_by' => $user, + 'current_step' => 'verify', + 'state' => array_filter([ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'tenant_name' => (string) $tenant->name, + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_operation_run_id' => $run instanceof OperationRun ? (int) $run->getKey() : null, + ], static fn (mixed $value): bool => $value !== null), + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + return [$user, $draft, $connection, $run, $missingKey]; +} + it('returns 404 for non-members when starting onboarding with a selected workspace', function (): void { $workspace = Workspace::factory()->create(); $user = User::factory()->create(); @@ -869,6 +1015,349 @@ ->assertSee('Draft Owner'); }); +it('shows route-bound readiness progress and check-not-run guidance with one primary next action', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(['name' => 'Readiness Owner']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->onboarding()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => '31313131-3131-3131-3131-313131313131', + 'name' => 'No Check Tenant', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + $connection = ProviderConnection::factory()->platform()->consentGranted()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'display_name' => 'No check connection', + 'is_default' => true, + ]); + + $draft = createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $tenant, + 'started_by' => $user, + 'updated_by' => $user, + 'current_step' => 'verify', + 'state' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'tenant_name' => (string) $tenant->name, + 'provider_connection_id' => (int) $connection->getKey(), + ], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $response = $this->actingAs($user) + ->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()])) + ->assertSuccessful() + ->assertSee('Onboarding readiness') + ->assertSee('Current checkpoint') + ->assertSee('Verify access') + ->assertSee('Verification has not run yet') + ->assertSee('Provider connection') + ->assertSee('Primary next action') + ->assertSee('Start verification'); + + expect(substr_count($response->getContent(), 'Primary next action'))->toBe(1); +}); + +it('shows route-bound ready readiness with freshness and canonical operation evidence', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->onboarding()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => '32323232-3232-3232-3232-323232323232', + 'name' => 'Ready Readiness Tenant', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + seedManagedReadinessPermissions($tenant, missingKey: '__none__'); + + $connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'display_name' => 'Ready connection', + 'is_default' => true, + ]); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_report' => VerificationReportWriter::build('provider.connection.check', [ + [ + 'key' => 'provider.connection.check', + 'title' => 'Provider connection check', + 'status' => 'pass', + 'severity' => 'info', + 'blocking' => false, + 'reason_code' => 'ok', + 'message' => 'Connection is healthy.', + 'evidence' => [], + 'next_steps' => [], + ], + ]), + ], + ]); + + $draft = createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $tenant, + 'started_by' => $user, + 'updated_by' => $user, + 'current_step' => 'complete', + 'state' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'tenant_name' => (string) $tenant->name, + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_operation_run_id' => (int) $run->getKey(), + ], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $this->actingAs($user) + ->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()])) + ->assertSuccessful() + ->assertSee('Ready for activation') + ->assertSee('Verification and permission evidence are current.') + ->assertSee('Complete onboarding') + ->assertSee('Supporting evidence') + ->assertSee('Open operation') + ->assertSee(route('admin.operations.view', ['run' => $run->getKey()]), false); +}); + +it('classifies consent, disabled connection, and blocked verification readiness blockers', function (string $state, string $summary, string $nextAction): void { + [$user, $draft] = createManagedReadinessBlockerDraft($state); + + $this->actingAs($user) + ->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()])) + ->assertSuccessful() + ->assertSee('Onboarding readiness') + ->assertSee($summary) + ->assertSee($nextAction); +})->with([ + 'missing consent' => ['missing_consent', 'Provider consent required', 'Grant consent'], + 'revoked consent' => ['revoked_consent', 'Provider consent revoked', 'Grant consent'], + 'disabled connection' => ['disabled_connection', 'Provider connection disabled', 'Review provider connection'], + 'blocked verification' => ['blocked_verification', 'Permission or consent blocker needs attention', 'Review permissions'], +]); + +it('keeps permission gap diagnostics provider-owned while top-level readiness stays neutral', function (): void { + [$user, $draft, , , $missingKey] = createManagedReadinessBlockerDraft('permission_gap'); + + $response = $this->actingAs($user) + ->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()])) + ->assertSuccessful() + ->assertSee('Permission or consent blocker needs attention') + ->assertSee('Permission diagnostics') + ->assertSee('Missing application permissions') + ->assertSee('Review permissions'); + + if (is_string($missingKey) && $missingKey !== '') { + $response->assertSee($missingKey); + } + + $response->assertDontSee('Microsoft Graph readiness'); +}); + +it('downgrades route-bound readiness when permission evidence is stale and keeps the operation link canonical', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->onboarding()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => '52525252-5252-5252-5252-525252525252', + 'name' => 'Stale Evidence Tenant', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + seedManagedReadinessPermissions($tenant, staleDays: 45, missingKey: '__none__'); + + $connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'display_name' => 'Stale readiness connection', + 'is_default' => true, + ]); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_report' => VerificationReportWriter::build('provider.connection.check', [ + [ + 'key' => 'provider.connection.check', + 'title' => 'Provider connection check', + 'status' => 'pass', + 'severity' => 'info', + 'blocking' => false, + 'reason_code' => 'ok', + 'message' => 'Connection is healthy.', + 'evidence' => [], + 'next_steps' => [], + ], + ]), + ], + ]); + + $draft = createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $tenant, + 'started_by' => $user, + 'updated_by' => $user, + 'current_step' => 'complete', + 'state' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'tenant_name' => (string) $tenant->name, + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_operation_run_id' => (int) $run->getKey(), + ], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $this->actingAs($user) + ->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()])) + ->assertSuccessful() + ->assertSee('Readiness needs attention') + ->assertSee('Permission data is older than the 30-day freshness window.') + ->assertSee('Rerun verification') + ->assertSee('Open operation') + ->assertSee(route('admin.operations.view', ['run' => $run->getKey()]), false); +}); + +it('downgrades route-bound readiness when verification evidence belongs to another selected connection', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->onboarding()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => '53535353-5353-5353-5353-535353535353', + 'name' => 'Mismatched Evidence Tenant', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + seedManagedReadinessPermissions($tenant, missingKey: '__none__'); + + $oldConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => '54545454-5454-5454-5454-545454545454', + 'display_name' => 'Previous connection', + ]); + $selectedConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'display_name' => 'Selected connection', + 'is_default' => true, + ]); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'context' => [ + 'provider_connection_id' => (int) $oldConnection->getKey(), + 'verification_report' => VerificationReportWriter::build('provider.connection.check', [ + [ + 'key' => 'provider.connection.check', + 'title' => 'Provider connection check', + 'status' => 'pass', + 'severity' => 'info', + 'blocking' => false, + 'reason_code' => 'ok', + 'message' => 'Connection is healthy.', + 'evidence' => [], + 'next_steps' => [], + ], + ]), + ], + ]); + + $draft = createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $tenant, + 'started_by' => $user, + 'updated_by' => $user, + 'current_step' => 'complete', + 'state' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'tenant_name' => (string) $tenant->name, + 'provider_connection_id' => (int) $selectedConnection->getKey(), + 'verification_operation_run_id' => (int) $run->getKey(), + ], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $this->actingAs($user) + ->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()])) + ->assertSuccessful() + ->assertSee('Verification needs refresh') + ->assertSee('Verification evidence belongs to a different provider connection.') + ->assertSee('Rerun verification') + ->assertSee('Open operation') + ->assertSee(route('admin.operations.view', ['run' => $run->getKey()]), false); +}); + it('resumes an existing draft for the same tenant instead of creating a duplicate', function (): void { $workspace = Workspace::factory()->create(); $user = User::factory()->create(); @@ -1171,6 +1660,10 @@ $component->call('startVerification'); $component->call('startVerification'); + $component + ->assertSee('Onboarding readiness') + ->assertSee('Open operation'); + expect(OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('type', 'provider.connection.check') diff --git a/apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php b/apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php index e81a63ec..24296260 100644 --- a/apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php +++ b/apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php @@ -259,6 +259,53 @@ ->assertActionEnabled('cancel_onboarding_draft'); }); +it('keeps destructive draft actions confirmation protected and capability gated', function (): void { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'status' => Tenant::STATUS_ONBOARDING, + ]); + $manager = User::factory()->create(); + + createUserWithTenant( + tenant: $tenant, + user: $manager, + role: 'manager', + workspaceRole: 'manager', + ensureDefaultMicrosoftProviderConnection: false, + ); + + $resumableDraft = createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $tenant, + 'started_by' => $manager, + 'updated_by' => $manager, + ]); + $cancelledDraft = createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $tenant, + 'started_by' => $manager, + 'updated_by' => $manager, + 'status' => 'cancelled', + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + Livewire::actingAs($manager) + ->test(ManagedTenantOnboardingWizard::class, [ + 'onboardingDraft' => (int) $resumableDraft->getKey(), + ]) + ->assertActionExists('cancel_onboarding_draft', fn (\Filament\Actions\Action $action): bool => $action->isConfirmationRequired()) + ->assertActionEnabled('cancel_onboarding_draft'); + + Livewire::actingAs($manager) + ->test(ManagedTenantOnboardingWizard::class, [ + 'onboardingDraft' => (int) $cancelledDraft->getKey(), + ]) + ->assertActionExists('delete_onboarding_draft_header', fn (\Filament\Actions\Action $action): bool => $action->isConfirmationRequired()) + ->assertActionEnabled('delete_onboarding_draft_header'); +}); + it('returns 404 for non-members when requesting a shared onboarding draft', function (): void { $workspace = Workspace::factory()->create(); $tenant = Tenant::factory()->create([ @@ -320,6 +367,71 @@ ->assertForbidden(); }); +it('keeps readiness routes hidden from non-members and wrong workspaces while capability denials stay forbidden', function (): void { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'status' => Tenant::STATUS_ONBOARDING, + 'name' => 'Readiness Authorization Tenant', + ]); + $owner = User::factory()->create(); + $nonMember = User::factory()->create(); + $wrongWorkspaceUser = User::factory()->create(); + $readonly = User::factory()->create(); + $wrongWorkspace = Workspace::factory()->create(); + + createUserWithTenant( + tenant: $tenant, + user: $owner, + role: 'owner', + workspaceRole: 'owner', + ensureDefaultMicrosoftProviderConnection: false, + ); + + createUserWithTenant( + tenant: $tenant, + user: $readonly, + role: 'readonly', + workspaceRole: 'readonly', + ensureDefaultMicrosoftProviderConnection: false, + ); + + \App\Models\WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $wrongWorkspace->getKey(), + 'user_id' => (int) $wrongWorkspaceUser->getKey(), + 'role' => 'owner', + ]); + + $draft = createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $tenant, + 'started_by' => $owner, + 'updated_by' => $owner, + 'state' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'tenant_name' => (string) $tenant->name, + ], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $this->actingAs($nonMember) + ->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()])) + ->assertNotFound(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $wrongWorkspace->getKey()); + + $this->actingAs($wrongWorkspaceUser) + ->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()])) + ->assertNotFound(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $this->actingAs($readonly) + ->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()])) + ->assertForbidden(); +}); + it('returns 403 for readonly members on cancelled draft summaries so delete controls never render', function (): void { $workspace = Workspace::factory()->create(); $tenant = Tenant::factory()->create([ diff --git a/apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php b/apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php index bd58332d..86b8c564 100644 --- a/apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php +++ b/apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php @@ -2,11 +2,47 @@ declare(strict_types=1); +use App\Models\OperationRun; +use App\Models\ProviderConnection; +use App\Models\Tenant; +use App\Models\TenantPermission; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; +use App\Support\Verification\VerificationReportWriter; use App\Support\Workspaces\WorkspaceContext; +function seedPickerReadinessPermissions(Tenant $tenant, ?int $staleDays = null): void +{ + $configured = array_merge( + config('intune_permissions.permissions', []), + config('entra_permissions.permissions', []), + ); + + foreach ($configured as $permission) { + if (! is_array($permission)) { + continue; + } + + $key = $permission['key'] ?? null; + + if (! is_string($key) || trim($key) === '') { + continue; + } + + TenantPermission::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'permission_key' => trim($key), + 'status' => 'granted', + 'details' => ['source' => 'picker-readiness-test'], + 'last_checked_at' => $staleDays === null ? now() : now()->subDays($staleDays), + ]); + } +} + it('shows a draft picker with resumable draft metadata when multiple drafts exist', function (): void { $workspace = Workspace::factory()->create(); $user = User::factory()->create(); @@ -124,3 +160,273 @@ ->assertDontSee('Completed Draft') ->assertDontSee('Cancelled Draft'); }); + +it('shows compact readiness snippets for multiple resumable drafts while keeping picker actions', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $blockedTenant = Tenant::factory()->onboarding()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => '41414141-4141-4141-4141-414141414141', + 'name' => 'Needs Connection Tenant', + ]); + $readyTenant = Tenant::factory()->onboarding()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => '42424242-4242-4242-4242-424242424242', + 'name' => 'Ready Picker Tenant', + ]); + + $user->tenants()->syncWithoutDetaching([ + $blockedTenant->getKey() => ['role' => 'owner'], + $readyTenant->getKey() => ['role' => 'owner'], + ]); + + seedPickerReadinessPermissions($readyTenant); + + $connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $readyTenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $readyTenant->tenant_id, + 'display_name' => 'Ready picker connection', + 'is_default' => true, + ]); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $readyTenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_report' => VerificationReportWriter::build('provider.connection.check', [ + [ + 'key' => 'provider.connection.check', + 'title' => 'Provider connection check', + 'status' => 'pass', + 'severity' => 'info', + 'blocking' => false, + 'reason_code' => 'ok', + 'message' => 'Connection is healthy.', + 'evidence' => [], + 'next_steps' => [], + ], + ]), + ], + ]); + + createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $blockedTenant, + 'started_by' => $user, + 'updated_by' => $user, + 'current_step' => 'connection', + 'state' => [ + 'entra_tenant_id' => (string) $blockedTenant->tenant_id, + 'tenant_name' => (string) $blockedTenant->name, + ], + ]); + + createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $readyTenant, + 'started_by' => $user, + 'updated_by' => $user, + 'current_step' => 'complete', + 'state' => [ + 'entra_tenant_id' => (string) $readyTenant->tenant_id, + 'tenant_name' => (string) $readyTenant->name, + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_operation_run_id' => (int) $run->getKey(), + ], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $this->actingAs($user) + ->get(route('admin.onboarding')) + ->assertSuccessful() + ->assertSee('Needs Connection Tenant') + ->assertSee('Ready Picker Tenant') + ->assertSee('Compact readiness') + ->assertSee('Provider connection required') + ->assertSee('Connect provider') + ->assertSee('Ready for activation') + ->assertSee('Verification and permission evidence are current.') + ->assertSee('Resume onboarding') + ->assertSee('View summary'); +}); + +it('shows stale and mismatched readiness cues across multiple drafts in the picker', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $staleTenant = Tenant::factory()->onboarding()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => '44444444-4444-4444-4444-444444444444', + 'name' => 'Picker Stale Tenant', + ]); + $mismatchTenant = Tenant::factory()->onboarding()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => '45454545-4545-4545-4545-454545454545', + 'name' => 'Picker Mismatch Tenant', + ]); + + $user->tenants()->syncWithoutDetaching([ + $staleTenant->getKey() => ['role' => 'owner'], + $mismatchTenant->getKey() => ['role' => 'owner'], + ]); + + seedPickerReadinessPermissions($staleTenant, staleDays: 45); + seedPickerReadinessPermissions($mismatchTenant); + + $staleConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $staleTenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $staleTenant->tenant_id, + 'display_name' => 'Stale picker connection', + 'is_default' => true, + ]); + $oldMismatchConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $mismatchTenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => '46464646-4646-4646-4646-464646464646', + 'display_name' => 'Old mismatch picker connection', + ]); + $selectedMismatchConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $mismatchTenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $mismatchTenant->tenant_id, + 'display_name' => 'Selected mismatch picker connection', + 'is_default' => true, + ]); + + $staleRun = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $staleTenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'context' => [ + 'provider_connection_id' => (int) $staleConnection->getKey(), + 'verification_report' => VerificationReportWriter::build('provider.connection.check', [ + [ + 'key' => 'provider.connection.check', + 'title' => 'Provider connection check', + 'status' => 'pass', + 'severity' => 'info', + 'blocking' => false, + 'reason_code' => 'ok', + 'message' => 'Connection is healthy.', + 'evidence' => [], + 'next_steps' => [], + ], + ]), + ], + ]); + $mismatchRun = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $mismatchTenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'context' => [ + 'provider_connection_id' => (int) $oldMismatchConnection->getKey(), + 'verification_report' => VerificationReportWriter::build('provider.connection.check', [ + [ + 'key' => 'provider.connection.check', + 'title' => 'Provider connection check', + 'status' => 'pass', + 'severity' => 'info', + 'blocking' => false, + 'reason_code' => 'ok', + 'message' => 'Connection is healthy.', + 'evidence' => [], + 'next_steps' => [], + ], + ]), + ], + ]); + + createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $staleTenant, + 'started_by' => $user, + 'updated_by' => $user, + 'current_step' => 'complete', + 'state' => [ + 'entra_tenant_id' => (string) $staleTenant->tenant_id, + 'tenant_name' => (string) $staleTenant->name, + 'provider_connection_id' => (int) $staleConnection->getKey(), + 'verification_operation_run_id' => (int) $staleRun->getKey(), + ], + ]); + + createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $mismatchTenant, + 'started_by' => $user, + 'updated_by' => $user, + 'current_step' => 'complete', + 'state' => [ + 'entra_tenant_id' => (string) $mismatchTenant->tenant_id, + 'tenant_name' => (string) $mismatchTenant->name, + 'provider_connection_id' => (int) $selectedMismatchConnection->getKey(), + 'verification_operation_run_id' => (int) $mismatchRun->getKey(), + ], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $this->actingAs($user) + ->get(route('admin.onboarding')) + ->assertSuccessful() + ->assertSee('Picker Stale Tenant') + ->assertSee('Picker Mismatch Tenant') + ->assertSee('Permission data is older than the 30-day freshness window.') + ->assertSee('Verification evidence belongs to a different provider connection.') + ->assertSee('Rerun verification'); +}); + +it('preserves the single-draft landing redirect instead of rendering compact readiness', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $draft = createOnboardingDraft([ + 'workspace' => $workspace, + 'started_by' => $user, + 'updated_by' => $user, + 'state' => [ + 'entra_tenant_id' => '43434343-4343-4343-4343-434343434343', + 'tenant_name' => 'Single Redirect Tenant', + ], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $this->actingAs($user) + ->get(route('admin.onboarding')) + ->assertRedirect(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()])); +}); diff --git a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsEmptyStateTest.php b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsEmptyStateTest.php index f58cbf66..708645af 100644 --- a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsEmptyStateTest.php +++ b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsEmptyStateTest.php @@ -12,7 +12,7 @@ $this->actingAs($user) ->get("/admin/tenants/{$tenant->external_id}/required-permissions") ->assertSuccessful() - ->assertSee('Keine Daten verfügbar') + ->assertSee('No data available') ->assertSee($expectedUrl, false) ->assertSee('Start verification'); }); diff --git a/apps/platform/tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php b/apps/platform/tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php index 9ff72587..4378a7f6 100644 --- a/apps/platform/tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php +++ b/apps/platform/tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php @@ -48,6 +48,41 @@ ->and($result->status())->toBe(404); }); +it('returns not found for workspace members missing linked tenant entitlement', function (): void { + $tenant = Tenant::factory()->onboarding()->create(); + $owner = User::factory()->create(); + $workspaceOnlyUser = User::factory()->create(); + + createUserWithTenant( + tenant: $tenant, + user: $owner, + role: 'owner', + workspaceRole: 'owner', + ensureDefaultMicrosoftProviderConnection: false, + ); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $workspaceOnlyUser->getKey(), + 'role' => 'owner', + ]); + + $draft = createOnboardingDraft([ + 'workspace' => $tenant->workspace, + 'tenant' => $tenant, + 'started_by' => $owner, + 'updated_by' => $owner, + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $result = app(TenantOnboardingSessionPolicy::class)->view($workspaceOnlyUser, $draft); + + expect($result)->toBeInstanceOf(Response::class) + ->and($result->allowed())->toBeFalse() + ->and($result->status())->toBe(404); +}); + it('returns an honest forbidden message for entitled actors missing onboarding capability', function (): void { $tenant = Tenant::factory()->onboarding()->create(); $readonlyUser = User::factory()->create(); diff --git a/apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php b/apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php index 250dbd34..9caf2212 100644 --- a/apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php +++ b/apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php @@ -32,3 +32,19 @@ expect($freshness['is_stale'])->toBeFalse(); }); + +it('keeps the thirty day freshness boundary exact to the second', function (): void { + $reference = CarbonImmutable::parse('2026-02-08 12:00:00'); + + $insideWindow = TenantRequiredPermissionsViewModelBuilder::deriveFreshness( + CarbonImmutable::parse('2026-01-09 12:00:01'), + $reference, + ); + $outsideWindow = TenantRequiredPermissionsViewModelBuilder::deriveFreshness( + CarbonImmutable::parse('2026-01-09 11:59:59'), + $reference, + ); + + expect($insideWindow['is_stale'])->toBeFalse() + ->and($outsideWindow['is_stale'])->toBeTrue(); +}); diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index b1a1ce28..6518dff0 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -77,6 +77,7 @@ ### Product Scalability & Self-Service Foundation - Plans, Entitlements & Billing Readiness: plan model, feature gates, tenant/workspace/user/report/export/retention limits, trial state, grace periods, billing status, and audited plan changes - Demo & Trial Readiness: seeded demo workspaces, sample tenants, sample baselines/findings/reports, demo reset support, trial provisioning checklist, and sample-data mode where appropriate - Customer-facing transparency hooks: product surfaces should be designed so customer read-only views, review workspaces, support requests, and review-pack downloads can reuse the same underlying entities instead of becoming parallel one-off features +- Private AI readiness hooks: support, review, diagnostic, and decision surfaces should be designed so later AI assistance can use governed context builders, data classification, usage budgets, local/private model policies, cache fingerprints, and human approval gates instead of direct feature-level AI calls **Active specs**: — (not yet specced) @@ -147,6 +148,7 @@ ### Solo-Founder SaaS Automation & Operating Readiness - AVV / DPA / TOM / Legal Pack: reusable customer-facing legal and data-processing artifacts aligned with the actual product data model and hosting setup - Security Trust Pack Light: hosting overview, data categories, least-privilege permission model, RBAC model, retention, backup, audit logging, subprocessors, and “what we do not store” documentation - Support Desk + AI Triage: support mailbox or ticket system, categories, priorities, macros, known issues, AI triage, answer drafts, and linkage to TenantPilot diagnostic packs +- Private AI Operating Model: default no customer/tenant data to public AI APIs, local/private-first AI processing, explicit customer/workspace opt-in for non-local providers, AI disclosure wording, and operating rules for model hosting, retention, cost, and approval - Knowledge Base Pipeline: public docs, onboarding docs, troubleshooting docs, internal runbooks, and a maintained source set for AI-assisted support - Monitoring & Incident Runbooks: uptime, queues, failed jobs, error tracking, backups, storage, certificates, Graph failure rates, status page, incident templates, postmortem templates, and customer communication templates - Release & Customer Communication Automation: customer changelog, release notes, support notes, migration notes, breaking-change markers, known limitations, and docs-update checklist @@ -180,12 +182,38 @@ ### Product Usage, Customer Health & Operational Controls **Depends on**: Self-Service Tenant Onboarding & Connection Readiness, Support Diagnostic Pack, Plans / Entitlements & Billing Readiness, ProviderConnection health, OperationRun truth, Findings workflow, StoredReports / EvidenceItems, and audit log foundation. **Scope direction**: Start with privacy-aware product telemetry, derived customer/workspace health indicators, and a minimal operational controls registry. Avoid building a full analytics platform, CRM, or customer-success suite in the first slice. +### Private AI Execution & Usage Governance Foundation +Strategic AI platform foundation for using AI inside TenantPilot without hard-coding public cloud AI calls, leaking tenant data, losing cost control, or forcing later rewrites. +**Goal**: Make AI local/private-first, explicitly governed, budgeted, cacheable, auditable, and human-approved. External public AI providers are disabled by default and only usable through workspace-level opt-in, data classification, redaction, usage limits, and approval gates. +**Why it matters**: TenantPilot sells governance, compliance readiness, evidence, and tenant trust. AI cannot be bolted on through direct feature-level API calls. The platform needs a reusable execution boundary so support summaries, finding explanations, review packs, decision packs, and customer communications can use AI later without rebuilding privacy, cost, provider, approval, and audit controls each time. +**Depends on**: Product Knowledge & Contextual Help, Support Diagnostic Pack, Decision Pack Contract & Approval Workflow, Product Usage & Adoption Telemetry, Plans / Entitlements & Billing Readiness, Operational Controls & Feature Flags, Security Trust Pack Light, audit log foundation, and workspace/RBAC isolation. +**Scope direction**: Build the foundation before broad AI features: AI use case registry, AI provider registry, workspace AI policy, AI data classification, AI context builders, AI policy gate, AI budget gate, AI result store/cache, AI usage ledger, and AI audit trail. Start with local/private and customer-hosted model compatibility; keep external provider support optional and explicit. + +**Core principles**: +- AI is never called directly from feature code; every AI action goes through governed use cases, policy gates, budget gates, context builders, provider adapters, cache/result storage, and audit trails +- Default posture: no customer or tenant data is sent to external public AI APIs +- Local/private/customer-hosted/EU-private model execution is the preferred path for tenant/customer data +- External public AI providers require explicit workspace opt-in, allowed data classes, redaction, budgets, disclosure, and human approval where needed +- Raw provider payloads, personal data, and customer-confidential context are never sent to external models by default +- Customer-facing, tenant-changing, risk-accepting, legal, or compliance-relevant AI outputs require human approval +- AI outputs should be fingerprinted, cacheable, source-linked, and reproducible enough for governance review + +**Foundation components**: +- AI Use Case Registry: allowed data classes, model classes, approval requirements, output visibility, retention, cacheability, cost ceiling, and context-size limits per use case +- AI Provider Registry: disabled, local/private, customer-hosted OpenAI-compatible, TenantPilot-private, EU-private, and external provider adapters with trust-boundary metadata +- Workspace AI Policy: disabled, local-only, private-only, EU-only, external-allowed-with-redaction, or explicit external-allowed modes +- AI Data Classification: product knowledge, operational metadata, tenant config summary, redacted provider payload, raw provider payload, personal data, customer-confidential context, and legal/compliance statements +- AI Context Builders: sanitized, purpose-specific context assembly instead of sending raw tenant/provider data to models +- AI Usage Budgeting: credits, monthly caps, model-tier routing, queue priority, cost estimates, and cache/fingerprint reuse +- AI Result Store & Cache: fingerprinted outputs with provenance, freshness, invalidation, source references, and reuse controls +- AI Audit Trail: use case, provider, model class, data class, purpose, context hash, output hash, cost estimate, cache hit/miss, approval state, and result usage + ### AI-Assisted Customer Operations -AI-assisted customer operations layer for support, reviews, summaries, release communication, and customer-facing explanations, explicitly bounded by human approval and product auditability. +AI-assisted customer operations layer for support, reviews, summaries, release communication, and customer-facing explanations, explicitly bounded by private AI execution policy, human approval, and product auditability. **Goal**: Use AI to prepare, summarize, classify, and draft customer operations work while keeping tenant-changing actions, customer commitments, legal statements, and external communications under human approval. -**Why it matters**: TenantPilot can stay lean only if support, customer reviews, release communication, and diagnostics are structured enough for AI assistance without becoming ungoverned automation. -**Depends on**: Product Knowledge & Contextual Help, Support Diagnostic Pack, In-App Support Request, StoredReports / EvidenceItems, Findings workflow maturity, release-note discipline, and company support-desk structure. -**Scope direction**: Start with AI-generated support summaries, finding explanations, tenant review summaries, diagnostic summaries, release-note drafts, and support-response drafts. Avoid autonomous tenant remediation, automatic risk acceptance, automatic legal commitments, or customer-facing messages without review. +**Why it matters**: TenantPilot can stay lean only if support, customer reviews, release communication, and diagnostics are structured enough for AI assistance without becoming ungoverned automation or uncontrolled public-model data processing. +**Depends on**: Private AI Execution & Usage Governance Foundation, Product Knowledge & Contextual Help, Support Diagnostic Pack, In-App Support Request, StoredReports / EvidenceItems, Findings workflow maturity, release-note discipline, and company support-desk structure. +**Scope direction**: Start with AI-generated support summaries, finding explanations, tenant review summaries, diagnostic summaries, release-note drafts, and support-response drafts. Prefer local/private execution for tenant/customer data. Avoid autonomous tenant remediation, automatic risk acceptance, automatic legal commitments, or customer-facing messages without review. ### Decision-Based Operating Foundations Constitution hardening for decision-first governance, workflow-first navigation, surface taxonomy, and primary-vs-evidence surface refactoring. @@ -309,6 +337,9 @@ ## Infrastructure & Platform Debt | No product usage/adoption telemetry yet | Founder cannot see onboarding drop-off, feature adoption, trial health, or support-triggering surfaces without manual investigation | Covered by Additional Solo-Founder Scale Guardrails | | No customer health score yet | Churn, inactive customers, stale reviews, unhealthy provider connections, and unresolved high-risk findings may be noticed too late | Covered by Additional Solo-Founder Scale Guardrails | | No explicit operational controls / feature flags lane | Incidents or risky features may require code changes or manual database intervention instead of safe operator controls | Covered by Additional Solo-Founder Scale Guardrails | +| No private AI execution foundation yet | Future AI features may call model providers directly, leak tenant context, become hard to audit, or require rewrites to support local/private models | Covered by Private AI Execution & Usage Governance Foundation | +| No AI usage budgeting / cost governance yet | AI-assisted summaries, decision packs, reviews, and support workflows may create uncontrolled compute/API costs and queue pressure | Covered by Private AI Execution & Usage Governance Foundation | +| No AI data classification / context-builder boundary yet | Raw provider payloads, personal data, or customer-confidential tenant context could be over-shared with models instead of sanitized purpose-specific context | Covered by Private AI Execution & Usage Governance Foundation | | No no-customization governance yet | Customer-specific requests can silently turn the product into consulting work and create hidden maintenance obligations | Covered by Additional Solo-Founder Scale Guardrails | | No business-continuity / founder-backup plan yet | Solo-founder operations create continuity risk for incidents, illness, vacation, access recovery, and customer trust | Covered by Additional Solo-Founder Scale Guardrails | | No `.env.example` in repo | Onboarding friction | Open | @@ -325,15 +356,16 @@ ## Priority Ranking (from Product Brainstorming) 1. Product Scalability & Self-Service Foundation 2. Product Usage, Customer Health & Operational Controls -3. Decision-Based Operating / Governance Inbox -4. MSP Portfolio + Alerting -5. Drift + Approval Workflows -6. Evidence / Review Packs + Customer Review Workspace -7. Standardization / Linting -8. Promotion DEV→PROD -9. Recovery Confidence -10. Solo-Founder SaaS Automation & Operating Readiness -11. Additional Solo-Founder Scale Guardrails +3. Private AI Execution & Usage Governance Foundation +4. Decision-Based Operating / Governance Inbox +5. MSP Portfolio + Alerting +6. Drift + Approval Workflows +7. Evidence / Review Packs + Customer Review Workspace +8. Standardization / Linting +9. Promotion DEV→PROD +10. Recovery Confidence +11. Solo-Founder SaaS Automation & Operating Readiness +12. Additional Solo-Founder Scale Guardrails --- @@ -344,5 +376,6 @@ ## How to use this file - **Company automation / solo-founder operating items** live here as strategic tracks first; only product-impacting or repeatable engineering work should become spec candidates. - **Solo-founder guardrails** should remain visible even when they are not immediate product specs, because they define what must become measurable, controllable, delegable, or documented before customer volume grows. - **Governance positioning is Microsoft-first, provider-extensible**: roadmap language should keep the initial product scope focused on Microsoft tenant governance while avoiding unnecessary Microsoft-only coupling in platform-level abstractions. +- **AI positioning is local/private-first and provider-adapter-based**: roadmap language should avoid direct feature-level public AI calls and instead route AI through use-case registries, data classification, context builders, policy gates, budget gates, provider adapters, audit trails, and human approval workflows. - **Small discoveries from implementation** → see [discoveries.md](discoveries.md) - **Product principles** → see [principles.md](principles.md) diff --git a/docs/product/spec-candidates.md b/docs/product/spec-candidates.md index 3c315eb0..ee710189 100644 --- a/docs/product/spec-candidates.md +++ b/docs/product/spec-candidates.md @@ -5,7 +5,7 @@ # Spec Candidates > > **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec` -> **Last reviewed**: 2026-04-25 (added Product Scalability & Self-Service Foundation candidates and Additional Solo-Founder Scale Guardrails candidates from roadmap: Product Usage & Adoption Telemetry, Customer Health Score, Operational Controls & Feature Flags, Customer Lifecycle Communication, Product Intake & No-Customization Governance, and Data Retention / Export / Deletion Self-Service; retained Codebase Quality & Engineering Maturity cluster and existing strategic hardening lanes) +> **Last reviewed**: 2026-04-25 (added Product Scalability & Self-Service Foundation candidates, Additional Solo-Founder Scale Guardrails candidates, Microsoft-first provider-extensible Decision-Based Operating candidates, and Private AI Execution & Usage Governance Foundation candidates; retained Codebase Quality & Engineering Maturity cluster and existing strategic hardening lanes) --- @@ -84,12 +84,16 @@ ## Qualified > 2. **Support Diagnostic Pack** > 3. **Product Usage & Adoption Telemetry** > 4. **Operational Controls & Feature Flags** -> 5. **Provider Identity & Target Scope Neutrality** -> 6. **Canonical Operation Type Source of Truth** -> 7. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys** -> 8. **Customer Review Workspace v1** +> 5. **Private AI Execution & Policy Foundation** +> 6. **AI Usage Budgeting, Context & Result Governance** +> 7. **Decision-Based Governance Inbox v1** +> 8. **Decision Pack Contract & Approval Workflow** +> 9. **Provider Identity & Target Scope Neutrality** +> 10. **Canonical Operation Type Source of Truth** +> 11. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys** +> 12. **Customer Review Workspace v1** > -> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining product risk is not only semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage; it is also founder-dependent onboarding/support and lack of product-side observability/control. Self-service onboarding, diagnostic packs, adoption telemetry, and operational controls therefore move ahead of broader expansion so TenantPilot becomes repeatably operable, measurable, and safe to run with low headcount. +> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining product risk is not only semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage; it is also founder-dependent onboarding/support, lack of product-side observability/control, ungoverned AI introduction risk, and customer-facing search-and-troubleshoot workflows. Self-service onboarding, diagnostic packs, adoption telemetry, operational controls, private AI execution governance, and a decision-based governance inbox therefore move ahead of broader expansion so TenantPilot becomes repeatably operable, measurable, AI-ready, and safe to run with low headcount while customers receive decision-ready work instead of raw troubleshooting surfaces. > Product Scalability & Self-Service Foundation cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to keep TenantPilot operable as a low-headcount, AI-assisted SaaS by productizing recurring onboarding, support, diagnostics, entitlement, help, demo, and customer-operations work. This cluster should not become a generic backoffice automation program. Only product-impacting or repeatable engineering work belongs here; pure company-ops work stays in the roadmap / operating system track. @@ -326,8 +330,8 @@ ### AI-Assisted Customer Operations - AI hallucination risk must be mitigated through structured inputs and source references - Privacy and data-processing boundaries need explicit review before customer data is sent to any model provider - The first version should probably be internal-only until diagnostics, knowledge, and support-request foundations are stable -- **Dependencies**: Support Diagnostic Pack, Product Knowledge & Contextual Help, In-App Support Request with Context, StoredReports / EvidenceItems, Findings workflow, release communication process, security/privacy review -- **Related specs / candidates**: Human-in-the-Loop Autonomous Governance, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Security Trust Pack Light +- **Dependencies**: Private AI Execution & Policy Foundation, AI Usage Budgeting, Context & Result Governance, Support Diagnostic Pack, Product Knowledge & Contextual Help, In-App Support Request with Context, StoredReports / EvidenceItems, Findings workflow, release communication process, security/privacy review +- **Related specs / candidates**: Private AI Execution & Policy Foundation, AI Usage Budgeting, Context & Result Governance, Human-in-the-Loop Autonomous Governance, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Security Trust Pack Light - **Strategic sequencing**: Mid-term. Do not promote before diagnostic, knowledge, and support-context foundations exist. - **Priority**: medium @@ -429,7 +433,7 @@ ### Operational Controls & Feature Flags - Operational controls must not bypass entitlement/RBAC semantics or become an untracked superpower - Too many flags can create configuration drift; start with high-risk controls only - Read-only modes need careful definition so evidence/audit access remains available -- **Dependencies**: System Panel Least-Privilege Capability Model, Provider-Backed Action Preflight and Dispatch Gate Unification, restore/provider action services, export/report services, audit log foundation, Plans / Entitlements & Billing Readiness +- **Dependencies**: System Panel Least-Privilege Capability Model, Provider-Backed Action Preflight and Dispatch Gate Unification, restore/provider action services, export/report services, AI execution controls, audit log foundation, Plans / Entitlements & Billing Readiness - **Related specs / candidates**: Provider-Backed Action Preflight and Dispatch Gate Unification, Plans / Entitlements & Billing Readiness, System Panel Least-Privilege Capability Model, Business Continuity / Founder Backup Plan - **Strategic sequencing**: High priority once external customers or pilots depend on production. Can be promoted before telemetry if incident-control risk becomes immediate. - **Priority**: high @@ -535,7 +539,84 @@ ### Data Retention, Export & Deletion Self-Service -> Microsoft-first, Provider-extensible Decision-Based Operating cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to move TenantPilot from Microsoft tenant search-and-troubleshoot workflows to decision-based governance operations. Customers and operators should not have to click through tenants, runs, findings, reports, logs, and evidence to discover what needs attention. TenantPilot should detect relevant work, gather context, summarize impact, propose safe next actions, and present decision-ready items. The first product scope stays Microsoft tenant governance; the decision model should avoid hard-coding Microsoft-only assumptions where provider-neutral abstractions already exist. + + +> Private AI Execution & Usage Governance Foundation cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to make AI a governed platform capability, not a set of direct feature-level public API calls. TenantPilot should be local/private-first for tenant/customer data, provider-adapter-based, budgeted, cacheable, auditable, and human-approved where risk matters. External public AI providers must be disabled by default and only usable through explicit workspace policy, data classification, redaction, budget limits, and approval gates. + +### Private AI Execution & Policy Foundation +- **Type**: AI platform foundation / privacy boundary / provider abstraction +- **Source**: roadmap update 2026-04-25 — Private AI Execution & Usage Governance Foundation +- **Problem**: Future AI-assisted summaries, diagnostics, review packs, decision packs, support responses, and customer communications will be risky if individual features call model providers directly. Direct calls would make it hard to support local/private models, enforce data boundaries, audit usage, control costs, or answer German enterprise customers' privacy and compliance questions. +- **Why it matters**: TenantPilot sells governance, compliance readiness, evidence, and tenant trust. AI must therefore be governed like a platform capability: use-case registered, data-classified, policy-gated, budget-gated, provider-adapted, audited, and human-approved where needed. The architecture must support local/private/customer-hosted/EU-private models without later rewrites. +- **Proposed direction**: + - introduce an AI Use Case Registry for approved AI use cases such as finding summaries, operation summaries, support diagnostic summaries, review-pack executive summaries, decision-pack recommendations, and release/customer communication drafts + - introduce an AI Provider Registry with provider classes such as disabled, local/private, customer-hosted OpenAI-compatible, TenantPilot-private, EU-private, and external public provider adapters + - introduce Workspace AI Policy modes such as disabled, local-only, private-only, EU-only, external-allowed-with-redaction, and explicit external-allowed + - introduce AI Data Classification for product knowledge, operational metadata, tenant config summaries, redacted provider payloads, raw provider payloads, personal data, customer-confidential context, and legal/compliance statements + - ensure AI execution is only possible through a central policy gate and provider adapter, never direct feature-level model calls + - default external public AI providers to disabled for customer/tenant data + - define capability/RBAC boundaries for managing AI settings and viewing AI execution metadata +- **Scope boundaries**: + - **In scope**: AI use-case registry, AI provider registry, workspace AI policy, AI data classification, policy evaluation service, provider adapter interface, initial disabled/local/private-compatible provider seam, RBAC/capability checks, and audit metadata shape + - **Out of scope**: building a full AI chatbot, implementing every provider, model benchmarking, autonomous remediation, legal final approval of AI disclosures, customer-facing AI UI for all use cases, or sending real tenant data to external providers +- **Acceptance points**: + - feature code cannot invoke AI without going through the central AI execution boundary + - every AI request declares use case, workspace, data class, model/provider class, purpose, and output visibility + - external public providers are disabled by default for tenant/customer data + - workspace AI policy can block or allow AI execution modes predictably + - raw provider payload and personal/customer-confidential data classes are rejected for external public providers by default + - AI policy decisions are auditable with actor/system actor, workspace, use case, provider class, data class, and decision outcome + - tests prove a disallowed provider/data-class combination cannot execute +- **Risks / open questions**: + - The first version must avoid overbuilding a provider marketplace before real AI use cases exist + - Local/private model support may initially be an adapter seam rather than a fully operated inference stack + - Workspace AI policy must be simple enough for operators but precise enough for enterprise trust conversations + - Data classification must align with Security Trust Pack Light and actual stored product data +- **Dependencies**: Security Trust Pack Light, Product Knowledge & Contextual Help, Support Diagnostic Pack, Decision Pack Contract & Approval Workflow, audit log foundation, workspace/RBAC isolation, Operational Controls & Feature Flags +- **Related specs / candidates**: AI Usage Budgeting, Context & Result Governance, AI-Assisted Customer Operations, Decision Pack Contract & Approval Workflow, Support Diagnostic Pack, Security Trust Pack Light +- **Strategic sequencing**: Should land before broad AI-assisted customer operations or decision recommendations. This is the safety and provider boundary for all later AI features. +- **Priority**: high + +### AI Usage Budgeting, Context & Result Governance +- **Type**: AI cost governance / context governance / result lifecycle foundation +- **Source**: roadmap update 2026-04-25 — Private AI Execution & Usage Governance Foundation +- **Problem**: Even with local/private models, AI usage consumes compute, queue capacity, latency budget, and potentially paid provider credits. Without usage budgeting, context builders, redaction, fingerprinting, result caching, and output governance, AI-assisted support, reviews, decision packs, and summaries can become expensive, slow, inconsistent, or unsafe. +- **Why it matters**: AI-native SaaS margins depend on treating AI calls as metered, prioritized, cacheable product operations. TenantPilot also needs to avoid sending raw provider payloads or excessive customer context to models. Structured context builders and result governance make AI outputs safer, cheaper, more stable, and easier to audit. +- **Proposed direction**: + - introduce an AI Usage Ledger for use case, workspace, tenant/reference, provider class, model class, data class, token/compute estimate, credit/cost estimate, queue priority, cache hit/miss, status, and purpose + - introduce AI credits or budget counters at workspace/plan level, with monthly caps, soft/hard limits, and operator override semantics + - introduce model-tier routing so low-risk summarization can use cheaper/local models while high-value decision recommendations can require stronger reasoning models and approval + - introduce purpose-specific AI Context Builders for finding, drift, operation run, support diagnostic, review pack, and decision pack use cases + - introduce redaction and minimization rules so context is sanitized, referenced, or summarized instead of passing raw tenant/provider data + - introduce AI Result Store & Cache keyed by fingerprints such as finding fingerprint, drift fingerprint, operation-run context hash, report fingerprint, evidence bundle fingerprint, and decision-pack fingerprint + - introduce approval gates and lifecycle states for customer-facing, legal/compliance, risk-accepting, or tenant-changing AI outputs + - expose basic operator visibility into AI usage, budget status, cache reuse, and blocked/failed AI jobs +- **Scope boundaries**: + - **In scope**: usage ledger, budget/credit service, context-builder contracts for first use cases, redaction hooks, result cache/store, fingerprinting, basic model-tier routing, queue priority metadata, approval state for sensitive outputs, and tests + - **Out of scope**: full billing integration, public customer AI usage dashboard, complex cost accounting, prompt marketplace, model fine-tuning, autonomous execution, or broad AI observability suite +- **Acceptance points**: + - AI jobs are recorded in a ledger with use case, workspace, provider/model class, data class, status, cache hit/miss, and cost/credit estimate + - workspace/plan AI budgets can block or degrade non-critical AI jobs when limits are exceeded + - at least one AI use case uses a context builder instead of raw model input from feature code + - result cache/fingerprint reuse prevents repeated generation for unchanged inputs + - customer-facing or risk-relevant AI outputs can remain draft/pending approval before use + - tests prove budget enforcement, cache reuse, redaction boundary, and approval-required output behavior +- **Risks / open questions**: + - Cost estimation may be approximate for local/private models; the system should support both token-cost and compute-credit abstractions + - Over-caching could reuse stale summaries if invalidation rules are weak + - Under-caching could make AI features too expensive and inconsistent + - Approval gates should not create UX friction for low-risk internal summaries +- **Dependencies**: Private AI Execution & Policy Foundation, Plans / Entitlements & Billing Readiness, Product Usage & Adoption Telemetry, Support Diagnostic Pack, StoredReports / EvidenceItems, Decision Pack Contract & Approval Workflow, OperationRun truth +- **Related specs / candidates**: AI-Assisted Customer Operations, Customer Lifecycle Communication, Product Knowledge & Contextual Help, Operational Controls & Feature Flags, Decision-Based Governance Inbox v1 +- **Strategic sequencing**: Should follow or pair with Private AI Execution & Policy Foundation. It should land before AI is used at scale for reviews, support, decision packs, or customer communication. +- **Priority**: high + +> Recommended sequence for this cluster: +> 1. **Private AI Execution & Policy Foundation** +> 2. **AI Usage Budgeting, Context & Result Governance** +> 3. **AI-Assisted Customer Operations** +> +> Why this order: first establish the trust boundary and provider/data policy, then add cost/context/result controls, and only then scale AI-assisted customer operations on top of governed inputs, budgets, caches, audits, and approvals. ### Decision-Based Governance Inbox v1 - **Type**: product strategy / workflow automation / operator UX @@ -595,7 +676,7 @@ ### Decision Pack Contract & Approval Workflow - Too much context can overwhelm operators; the pack must be concise with progressive disclosure - Recommendations must not overstate certainty; confidence/freshness must be visible - AI-generated recommendations should remain optional and clearly marked until AI governance boundaries are mature -- **Dependencies**: Decision-Based Governance Inbox v1, Support Diagnostic Pack, Product Knowledge & Contextual Help, OperationRun link contract, Findings workflow, StoredReports / EvidenceItems, Operational Controls & Feature Flags, audit log foundation +- **Dependencies**: Decision-Based Governance Inbox v1, Support Diagnostic Pack, Product Knowledge & Contextual Help, Private AI Execution & Policy Foundation, AI Usage Budgeting, Context & Result Governance, OperationRun link contract, Findings workflow, StoredReports / EvidenceItems, Operational Controls & Feature Flags, audit log foundation - **Related specs / candidates**: AI-Assisted Customer Operations, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Provider-Backed Action Preflight and Dispatch Gate Unification, Customer Lifecycle Communication - **Strategic sequencing**: Should follow or pair with Governance Inbox v1. The inbox defines the work queue; decision packs make each item decision-ready. - **Priority**: high diff --git a/specs/240-tenant-onboarding-readiness/checklists/requirements.md b/specs/240-tenant-onboarding-readiness/checklists/requirements.md new file mode 100644 index 00000000..434a4d1c --- /dev/null +++ b/specs/240-tenant-onboarding-readiness/checklists/requirements.md @@ -0,0 +1,37 @@ +# Specification Quality Checklist: Self-Service Tenant Onboarding & Connection Readiness + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-25 +**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 + +- Quality pass completed on 2026-04-25. +- The spec stays intentionally narrow: it reuses existing onboarding session, provider connection, verification, and checkpoint truth and does not introduce new onboarding persistence. +- Provider-specific Microsoft details remain contextual inside diagnostics instead of becoming new platform-core truth. +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` diff --git a/specs/240-tenant-onboarding-readiness/contracts/onboarding-readiness.openapi.yaml b/specs/240-tenant-onboarding-readiness/contracts/onboarding-readiness.openapi.yaml new file mode 100644 index 00000000..e5171b23 --- /dev/null +++ b/specs/240-tenant-onboarding-readiness/contracts/onboarding-readiness.openapi.yaml @@ -0,0 +1,266 @@ +openapi: 3.0.3 +info: + title: TenantPilot Admin — Onboarding Readiness Workflow (Conceptual) + version: 0.1.0 + description: | + Conceptual HTTP contract for the operator-facing onboarding readiness workflow. + + NOTE: These routes are implemented as Filament (Livewire) pages and existing + actions. The exact Livewire payload shape is not part of this contract; this + file captures the user-visible routes, authorization semantics, and logical + view-model expectations. +servers: + - url: /admin +paths: + /onboarding: + get: + summary: View onboarding landing or draft picker + description: | + Workspace-scoped onboarding entry point. + + Behavior: + - No workspace selected: redirect to `/admin/choose-workspace` + - Non-member or wrong workspace: 404 + - Workspace member without onboarding capability: 403 + - One resumable draft: redirect to `/admin/onboarding/{onboardingDraft}` + - Multiple resumable drafts: render the draft picker with compact readiness snippets + responses: + '200': + description: Landing picker rendered + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/OnboardingLandingView' + '302': + description: Redirect to choose-workspace or the single resumable draft + '403': + description: Forbidden (workspace member lacks onboarding capability) + '404': + description: Not found (non-member or wrong workspace) + /onboarding/{onboardingDraft}: + get: + summary: View onboarding draft readiness workflow + description: | + Renders the existing managed-tenant onboarding wizard with a derived + readiness summary, freshness cues, and one primary next action. + + Authorization: + - Non-member or wrong workspace: 404 + - Missing linked-tenant entitlement: 404 + - Workspace member without onboarding capability: 403 + parameters: + - name: onboardingDraft + in: path + required: true + schema: + type: integer + description: Internal `managed_tenant_onboarding_sessions.id` + responses: + '200': + description: Onboarding draft workflow rendered + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/OnboardingReadinessView' + '403': + description: Forbidden (workspace member lacks onboarding capability) + '404': + description: Not found (non-member, wrong workspace, or missing linked-tenant entitlement) + /onboarding/{onboardingDraft}/actions/start-verification: + post: + summary: Start or rerun verification from the onboarding readiness workflow + description: | + Conceptual contract for the existing wizard verification action. + This feature must preserve current authorization, audit, dedupe, and + shared OperationRun start UX semantics. + parameters: + - name: onboardingDraft + in: path + required: true + schema: + type: integer + responses: + '202': + description: Verification accepted/queued + '403': + description: Forbidden (member lacks verification-start capability) + '404': + description: Not found (non-member, wrong workspace, or missing linked-tenant entitlement) + /onboarding/{onboardingDraft}/actions/complete: + post: + summary: Complete onboarding when readiness allows activation + description: | + Conceptual contract for the existing owner-gated completion action. + The action remains confirmation-protected and audited. + parameters: + - name: onboardingDraft + in: path + required: true + schema: + type: integer + responses: + '204': + description: Onboarding completed + '403': + description: Forbidden (member lacks activation capability) + '404': + description: Not found (non-member, wrong workspace, or missing linked-tenant entitlement) + /operations/{run}: + get: + summary: Open canonical supporting operation from onboarding readiness + description: | + Existing canonical tenantless operation-detail route linked from the + onboarding readiness workflow when supporting verification or bootstrap + evidence exists. + parameters: + - name: run + in: path + required: true + schema: + type: integer + description: Internal `operation_runs.id` + responses: + '200': + description: Operation detail rendered + content: + text/html: + schema: + type: string + '403': + description: Forbidden (member lacks permission for an action on the page) + '404': + description: Not found (run inaccessible under current workspace/tenant scope) +components: + schemas: + OnboardingLandingView: + type: object + required: + - mode + - drafts + properties: + mode: + type: string + enum: [start_state, single_redirect, draft_picker] + drafts: + type: array + items: + $ref: '#/components/schemas/OnboardingDraftCard' + primary_action: + $ref: '#/components/schemas/NextAction' + nullable: true + OnboardingDraftCard: + type: object + required: + - draft_id + - tenant_name + - current_stage + - readiness_summary + - next_action + properties: + draft_id: + type: integer + tenant_name: + type: string + current_stage: + type: string + readiness_summary: + type: string + freshness_note: + type: string + nullable: true + next_action: + $ref: '#/components/schemas/NextAction' + OnboardingReadinessView: + type: object + required: + - draft + - readiness + - next_action + properties: + draft: + type: object + required: + - id + - tenant_name + - current_stage + properties: + id: + type: integer + tenant_name: + type: string + current_stage: + type: string + started_by: + type: string + nullable: true + updated_by: + type: string + nullable: true + readiness: + type: object + required: + - lifecycle_state + - summary + properties: + lifecycle_state: + type: string + summary: + type: string + checkpoint: + type: string + nullable: true + provider_summary: + type: string + nullable: true + freshness_note: + type: string + nullable: true + blocker_reason: + type: string + nullable: true + next_action: + $ref: '#/components/schemas/NextAction' + supporting_links: + type: array + items: + $ref: '#/components/schemas/LinkAction' + NextAction: + type: object + required: + - label + - kind + properties: + label: + type: string + kind: + type: string + enum: + - start_onboarding + - resume_draft + - grant_consent + - review_permissions + - start_verification + - rerun_verification + - open_operation + - review_bootstrap + - complete_onboarding + url: + type: string + nullable: true + action_name: + type: string + nullable: true + LinkAction: + type: object + required: + - label + - url + properties: + label: + type: string + url: + type: string \ No newline at end of file diff --git a/specs/240-tenant-onboarding-readiness/data-model.md b/specs/240-tenant-onboarding-readiness/data-model.md new file mode 100644 index 00000000..a26dd1a7 --- /dev/null +++ b/specs/240-tenant-onboarding-readiness/data-model.md @@ -0,0 +1,140 @@ +# Data Model — Self-Service Tenant Onboarding & Connection Readiness + +**Spec**: [spec.md](spec.md) + +No new persistent tables are required for this slice. Readiness is computed at render time from existing onboarding, provider connection, verification, and permission-posture truth. + +## Entities + +### TenantOnboardingSession (`managed_tenant_onboarding_sessions`) + +**Purpose**: Workspace-scoped onboarding workflow record that owns resumability, checkpoint progression, and links to the managed tenant once identified. + +**Key fields (existing)**: +- `workspace_id` (required FK) +- `tenant_id` (nullable FK to `tenants.id` until the tenant is linked) +- `entra_tenant_id` +- `current_step` +- `version` +- `lifecycle_state` +- `current_checkpoint` +- `last_completed_checkpoint` +- `reason_code` +- `blocking_reason_code` +- `completed_at`, `cancelled_at` +- `state` JSON, constrained by `TenantOnboardingSession::STATE_ALLOWED_KEYS` + +**Relevant `state` keys (existing)**: +- `tenant_name` +- `primary_domain` +- `provider_connection_id` +- `selected_provider_connection_id` +- `verification_operation_run_id` +- `bootstrap_operation_types` +- `bootstrap_operation_runs` +- `connection_recently_updated` + +**Relationships (existing)**: +- Belongs to `Workspace` +- May belong to `Tenant` +- Belongs to `startedByUser` +- Belongs to `updatedByUser` + +### ProviderConnection (`provider_connections`) + +**Purpose**: Tenant-owned provider access record whose consent, verification, and target-scope state inform onboarding readiness. + +**Key fields (existing, relevant)**: +- `workspace_id` +- `tenant_id` +- `provider` +- `display_name` +- `connection_type` +- `is_default` +- `is_enabled` +- `consent_status` +- `verification_status` +- target-scope identity fields consumed by `ProviderConnectionTargetScopeNormalizer` + +**Relationships / invariants (existing)**: +- The selected provider connection must belong to the same workspace and tenant as the onboarding draft. +- `ProviderConnectionSurfaceSummary::forConnection()` is the shared source for provider summary wording and contextual identity detail. + +### VerificationRunEvidence (`operation_runs`, existing subset) + +**Purpose**: Existing supporting evidence for verification and bootstrap readiness, including canonical operation detail links. + +**Key fields (existing, relevant)**: +- `workspace_id` +- `tenant_id` +- `type` +- `status` +- `outcome` +- `context.provider_connection_id` +- `context.verification_report` +- `summary_counts` + +**Constraints / invariants (existing)**: +- The verification run must belong to the same workspace and tenant as the onboarding draft. +- Verification evidence is only trustworthy for readiness when `context.provider_connection_id` matches the draft’s selected provider connection. +- Canonical evidence links must continue to flow through `OperationRunLinks` / tenantless operation helpers. + +### PermissionPostureOverview (derived from existing permission posture data) + +**Purpose**: Existing stored permission comparison summary used by onboarding verification assist and readiness freshness cues. + +**Source (existing)**: +- `TenantPermissionService::compare(...)` +- `TenantRequiredPermissionsViewModelBuilder::build(...)` + +**Relevant derived fields (existing)**: +- `overview.overall` +- `overview.counts.missing_application` +- `overview.counts.missing_delegated` +- `overview.counts.error` +- `overview.freshness.last_refreshed_at` +- `overview.freshness.is_stale` + +**Invariant (existing)**: +- Permission freshness is stale when no refresh exists or `last_refreshed_at` is older than 30 days (`TenantRequiredPermissionsViewModelBuilder::deriveFreshness`). + +### OnboardingReadinessSummary (computed, not persisted) + +**Purpose**: Operator-facing derived summary rendered on the onboarding landing picker and route-bound draft view. + +**Proposed runtime shape (presentation-only)**: +- `draft`: `id`, `tenant_name`, `stage_label`, `draft_status_label`, `started_by`, `updated_by`, `last_updated_human` +- `checkpoint`: `current_checkpoint`, `last_completed_checkpoint`, `lifecycle_state` +- `provider_summary`: `readiness_summary`, `consent_state`, `verification_state`, `target_scope_summary`, `contextual_identity_line` +- `verification`: `status`, `overall`, `run_id`, `run_url`, `is_active`, `matches_selected_connection` +- `freshness`: `connection_recently_updated`, `verification_mismatch`, `permission_last_refreshed_at`, `permission_data_is_stale` +- `blocker`: `reason_code`, `blocking_reason_code`, `operator_summary` +- `next_action`: `label`, `kind`, `url_or_action`, `required_capability` +- `supporting_links`: `operation_url`, `tenant_url`, `consent_url` when already available from existing routes/helpers + +**Important rule**: This is a presentation shape only. It must map directly from existing onboarding lifecycle, provider connection, verification, and permission-posture truth. It is not a new domain model or persisted state family. + +## Derived Rules / Invariants + +- A draft without tenant identity cannot be ready; the primary action remains the identify-tenant step. +- A draft without a selected provider connection cannot be ready; the primary action remains connect/select provider. +- A verification run that does not match the selected provider connection is stale for readiness and must force a non-ready outcome. +- `connection_recently_updated=true` invalidates previous verification trust until verification reruns. +- Stale permission posture (`overview.freshness.is_stale=true`) must surface as a readiness attention cue or diagnostic freshness cue, not as ready. +- Top-level readiness wording stays platform-neutral. Provider-specific permission names and consent instructions remain inside secondary diagnostics. +- Supporting evidence uses canonical operation links only; no page-local run URLs are introduced. + +## Rendering Precedence (derived, not persisted) + +No new persisted transitions are introduced. The readiness summary should follow this rendering precedence when choosing one primary next action: + +1. No identified tenant: `Identify tenant` +2. No selected provider connection: `Connect provider` +3. Consent missing or revoked: `Grant consent` +4. Permission diagnostics blocked or incomplete: `Review permissions` / `Grant consent` as dictated by existing provider-owned diagnostics +5. Verification missing, stale, or mismatched: `Start verification` or `Rerun verification` +6. Verification active: `Open operation` or `Refresh` +7. Bootstrap selected and still active/failed: `Review bootstrap` +8. Lifecycle ready for activation: `Complete onboarding` + +The multi-draft landing surface uses the same precedence, but only in compact form so the operator can choose the correct draft to open. \ No newline at end of file diff --git a/specs/240-tenant-onboarding-readiness/plan.md b/specs/240-tenant-onboarding-readiness/plan.md new file mode 100644 index 00000000..909f4886 --- /dev/null +++ b/specs/240-tenant-onboarding-readiness/plan.md @@ -0,0 +1,196 @@ +# Implementation Plan: Self-Service Tenant Onboarding & Connection Readiness + +**Branch**: `240-tenant-onboarding-readiness` | **Date**: 2026-04-25 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +- Add one operator-facing, derived readiness summary to the existing managed-tenant onboarding workflow: the route-bound draft at `/admin/onboarding/{onboardingDraft}` always shows full readiness, while the `/admin/onboarding` landing surface adds compact readiness only when multiple resumable drafts exist and preserves the existing single-draft redirect behavior. +- Reuse current onboarding draft lifecycle, provider connection summary, verification diagnostics, the existing 30-day permission freshness rule, and canonical OperationRun links instead of adding new persistence, readiness enums, or provider-generic onboarding frameworks. +- Keep the workflow DB-only at render time, workspace- and tenant-safe, and centered on one operator decision: what blocks this tenant now and what the next action is. +- Defer numeric completion scoring and any cross-surface readiness abstraction; later consumers must reuse the same derived onboarding truth instead of broadening this slice. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers +**Storage**: PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned +**Testing**: Pest feature + unit tests (Filament/Livewire feature coverage plus policy/unit coverage) +**Validation Lanes**: fast-feedback +**Target Platform**: Sail-backed Laravel admin panel under `/admin` +**Project Type**: web +**Performance Goals**: onboarding landing and draft routes remain DB-only at render/hydration, compose readiness in-request from existing records, and avoid outbound HTTP or new queue starts during page render +**Constraints**: preserve workspace isolation, linked-tenant entitlement checks, existing RBAC capability boundaries, provider-boundary neutrality in top-level wording, current destructive confirmation patterns, current OperationRun start UX, and no new tables/enums/frameworks for readiness +**Scale/Scope**: one workspace-scoped onboarding workflow surface, one derived readiness composition, and focused onboarding/policy coverage only + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament + shared primitives +- **Shared-family relevance**: status messaging, action links, badges, embedded diagnostics, navigation +- **State layers in scope**: page + workflow-step + derived summary +- **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 +- **Exception path and spread control**: existing guided-workflow exception for `ManagedTenantOnboardingWizard`; no new surface exemption +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `ManagedTenantOnboardingWizard`, onboarding landing/draft picker, `OnboardingLifecycleService`, `OnboardingDraftStageResolver`, `ProviderConnectionSurfaceSummary`, `VerificationAssistViewModelBuilder`, `TenantRequiredPermissionsViewModelBuilder`, `OperationRunLinks` +- **Shared abstractions reused**: `OnboardingLifecycleService`, `OnboardingDraftStageResolver`, `ProviderConnectionSurfaceSummary`, `VerificationAssistViewModelBuilder`, `TenantRequiredPermissionsViewModelBuilder`, `OperationRunLinks`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter` +- **New abstraction introduced? why?**: none; readiness stays a thin derived composition on the existing workflow surface +- **Why the existing abstraction was sufficient or insufficient**: existing services already own checkpoint progression, provider connection wording, permission freshness, verification diagnostics, and canonical run links; the missing piece is a single operator-facing composition and action prioritization +- **Bounded deviation / spread control**: provider-specific consent and permission names remain inside existing diagnostic detail and provider-owned next steps; top-level readiness copy stays platform-neutral, and any shared builder extension must stay additive and behavior-preserving for non-onboarding consumers while onboarding-specific prioritization remains page-local + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes +- **Central contract reused**: shared OperationRun UX layer via `OperationRunLinks`, `OperationUxPresenter`, and the existing onboarding `tenantlessOperationRunUrl(...)` helper +- **Delegated UX behaviors**: queued toast, dedupe-or-blocked messaging, canonical `Open operation` link labeling, tenant/workspace-safe run URL resolution, and terminal lifecycle notifications remain delegated to shared Ops-UX paths +- **Surface-owned behavior kept local**: readiness wording, freshness explanation, and next-action prioritization inside the onboarding workflow +- **Queued DB-notification policy**: unchanged explicit opt-in; this slice does not add queued or running DB notifications +- **Terminal notification path**: central lifecycle mechanism already used by reused verification/bootstrap actions +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes +- **Provider-owned seams**: consent diagnostics, missing-permission detail, provider-specific remediation next steps, target-scope identity detail +- **Platform-core seams**: readiness summary, checkpoint progress, next-action labels, freshness messaging, workflow routing +- **Neutral platform terms / contracts preserved**: `provider connection`, `readiness`, `diagnostics`, `next action`, `freshness`, `onboarding step` +- **Retained provider-specific semantics and why**: Microsoft permission names and consent language stay inside existing verification assist and provider summary detail because the operator needs exact remediation instructions for the supported provider +- **Bounded extraction or follow-up path**: none; if a second provider appears later, revisit only the provider-owned detail seam rather than the workflow shell + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Persisted truth / proportionality: PASS — readiness remains derived from existing onboarding, provider connection, operation-run, and permission-posture truth; no new table, enum, or state family is planned. +- Read/write separation / destructive actions: PASS — this slice changes operator understanding on an existing wizard; existing cancel/delete/complete actions retain current confirmation, audit, and authorization rules. +- Graph contract path / DB-only render: PASS — render and Livewire hydration stay DB-only, reusing stored provider connection, verification, and permission-posture data with no new Graph calls. +- RBAC / isolation: PASS — existing workspace membership, linked-tenant entitlement, and `Capabilities::*` gates remain authoritative; non-members and wrong-scope actors stay 404, capability-denied members stay 403. +- Ops-UX / OperationRun link semantics: PASS — existing verification/bootstrap starts and run links continue to use shared OperationRun UX and canonical tenantless route helpers; no new run-start path is introduced. +- Provider boundary / shared pattern reuse: PASS — provider-neutral readiness remains top-level, Microsoft-specific details stay contextual, and existing shared builders/helpers are reused before any local deviation. +- Test governance: PASS — proof stays in fast-feedback feature/unit coverage using existing onboarding fixtures; no new browser or heavy-governance family is required. +- Global search / panel registration: N/A — this slice changes a Filament page, not a global-searchable resource or panel provider. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Feature (wizard + landing/picker rendering and behavior) plus targeted Unit/Policy coverage for isolation semantics and existing freshness helper behavior where directly reused +- **Affected validation lanes**: fast-feedback +- **Why this lane mix is the narrowest sufficient proof**: the workflow is server-driven Filament/Livewire UI backed by existing models and policies, so feature tests can prove readiness composition, next-action precedence, and deny semantics without browser automation +- **Narrowest proving command(s)**: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php`; `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`; `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php` +- **Fixture / helper / factory / seed / context cost risks**: reuse existing onboarding draft, workspace membership, tenant membership, provider connection, and operation-run fixtures; avoid adding browser fixtures or new provider mocks +- **Expensive defaults or shared helper growth introduced?**: no; any new test helpers should stay onboarding-local +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: standard-native-filament guided-workflow exception already exists; add feature assertions to cover the readiness summary on both landing and draft routes +- **Closing validation and reviewer handoff**: rerun the targeted onboarding wizard, draft-picker/authorization, and policy/freshness tests after implementation; reviewers should verify one primary next action, correct 404 vs 403 semantics, and no render-time outbound HTTP +- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep +- **Review-stop questions**: Does the slice stay in fast-feedback? Did any new fixture force browser/heavy coverage? Did the implementation introduce a second readiness taxonomy or persisted truth? +- **Escalation path**: document-in-feature if a shared helper needs a tiny extension; reject-or-split if implementation tries to add new persistence/frameworks or browser-lane proof +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: the planned proof extends already-existing onboarding suites and does not add a new recurring test cost center + +### Implementation Close-Out — 2026-04-25 + +- **Guardrail result**: keep. The implementation stayed inside the existing guided onboarding page, reused current lifecycle/stage, provider summary, verification assist, permission freshness, and OperationRun link paths, and did not add persistence, enums, a readiness taxonomy, or a cross-surface framework. +- **Fast-feedback proof**: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php` passed with 55 tests and 212 assertions. +- **Provider-boundary / lane-cost decision**: keep. Provider-specific permission and consent detail remains secondary and provider-owned; top-level readiness wording stays platform-neutral. No browser, heavy-governance, new fixture family, or follow-up spec is required. +- **Explicit defers retained**: numeric completion score and cross-surface readiness reuse remain deferred; future consumers should reuse this derived onboarding truth rather than broaden this slice. + +## Project Structure + +### Documentation (this feature) + +```text +specs/240-tenant-onboarding-readiness/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── checklists/ +│ └── requirements.md +├── contracts/ +│ └── onboarding-readiness.openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +│ ├── Models/TenantOnboardingSession.php +│ ├── Services/Intune/TenantRequiredPermissionsViewModelBuilder.php +│ ├── Services/Onboarding/ +│ │ ├── OnboardingDraftStageResolver.php +│ │ └── OnboardingLifecycleService.php +│ ├── Support/OperationRunLinks.php +│ ├── Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php +│ └── Support/Verification/VerificationAssistViewModelBuilder.php +└── tests/ + ├── Feature/ManagedTenantOnboardingWizardTest.php + ├── Feature/Onboarding/OnboardingDraftAuthorizationTest.php + ├── Feature/Onboarding/OnboardingDraftPickerTest.php + ├── Unit/Policies/TenantOnboardingSessionPolicyTest.php + └── Unit/TenantRequiredPermissionsFreshnessTest.php +``` + +**Structure Decision**: Single Laravel web application. This feature remains confined to the existing onboarding wizard/landing workflow, existing onboarding + provider support services, and focused onboarding/policy tests. + +## Complexity Tracking + +No constitution violations are required for this feature. The plan explicitly avoids new persistence, abstractions, readiness enums, or cross-surface frameworks. + +## Proportionality Review + +N/A — the plan intentionally keeps readiness derived and local to the existing onboarding workflow. No new persisted entity, abstraction layer, enum/status family, or taxonomy is proposed. + +## Phase 0 — Research (output: `research.md`) + +See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/research.md` + +Goals: +- Confirm the narrowest existing sources of truth for onboarding readiness, draft landing metadata, provider summary, permission freshness, and canonical operation links. +- Resolve the freshness question by reusing the existing 30-day permission freshness rule plus current connection-change and selected-connection-mismatch signals rather than introducing a new onboarding-specific threshold or persisted freshness model. +- Confirm that landing-route behavior remains the same surface (single-draft redirect or multi-draft picker) so readiness context is added without creating a second onboarding register. + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +See: +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/data-model.md` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/contracts/onboarding-readiness.openapi.yaml` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/quickstart.md` + +Design focus: +- Add one derived readiness composition on `ManagedTenantOnboardingWizard` and the existing draft picker using current draft lifecycle, provider connection summary, verification assist, the existing 30-day permission freshness rule, and explicit "check has not run yet" guidance where no evidence exists. +- Keep landing-route behavior intact: a single resumable draft still redirects to the route-bound draft; a multi-draft landing view gains compact readiness snippets next to existing metadata. +- Define next-action precedence from existing truth only: identify tenant → connect provider → grant consent/fix permissions → run or rerun verification → review bootstrap → complete onboarding. +- Keep permission and consent detail secondary and provider-owned; keep `Open operation` and consent links canonical and shared. +- Keep onboarding-specific readiness wording and action prioritization local to the onboarding workflow; if a shared provider or verification builder needs extension, keep the change additive and regression-safe for existing non-onboarding consumers. +- Defer numeric completion score and any support-diagnostic or trial/demo-specific projection to later specs that reuse the same derived onboarding truth. +- Do not add new tables, readiness enums, global resources, or provider-generic onboarding abstractions. + +## Phase 1 — Agent Context Update + +After Phase 1 artifacts are generated, update Copilot context from the plan: + +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot` + +## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`) + +- Extend onboarding landing and draft rendering to surface a derived readiness summary, freshness, and one primary next action from existing services/helpers. +- Reuse `OnboardingLifecycleService` snapshot truth, `OnboardingDraftStageResolver`, `ProviderConnectionSurfaceSummary`, `VerificationAssistViewModelBuilder`, and `TenantRequiredPermissionsViewModelBuilder` to map existing records into operator-facing copy. +- Add compact readiness metadata to the multi-draft picker and route-bound resume context without creating a new onboarding index resource. +- Preserve existing consent, verification, bootstrap, operation-link, and destructive header actions; only change prioritization, grouping emphasis, and explanatory copy. +- Expand targeted feature and policy tests for readiness states, freshness/mismatch handling, landing summary behavior, and 404 vs 403 regressions. + +## Constitution Check (Post-Design) + +Re-check result: PASS. Design artifacts keep readiness derived, DB-only at render time, workspace/tenant safe, Ops-UX compliant, and constrained to existing onboarding + provider foundations. diff --git a/specs/240-tenant-onboarding-readiness/quickstart.md b/specs/240-tenant-onboarding-readiness/quickstart.md new file mode 100644 index 00000000..18afbb50 --- /dev/null +++ b/specs/240-tenant-onboarding-readiness/quickstart.md @@ -0,0 +1,39 @@ +# Quickstart — Self-Service Tenant Onboarding & Connection Readiness + +## Prereqs + +- Docker running +- Laravel Sail dependencies installed +- Current feature branch checked out: `240-tenant-onboarding-readiness` + +## Run locally + +- Start containers: `cd apps/platform && ./vendor/bin/sail up -d` +- Run targeted validation after implementation: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php` +- Format after implementation: `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## Manual smoke (after implementation) + +1. Select a workspace and open `/admin/onboarding`. +2. With multiple resumable drafts, confirm each draft card shows current stage, readiness summary, freshness cue, and one primary next action. +3. Open a draft with missing consent and confirm the workflow points to consent or permission remediation instead of generic incomplete-state copy. +4. Open a draft with a changed provider connection or mismatched verification run and confirm readiness falls back to needs-attention with a canonical `Open operation` link when evidence exists. +5. Open the workflow as a wrong-workspace actor or actor without linked-tenant entitlement and confirm 404; open as a workspace member without onboarding capability and confirm 403 on protected actions. + +## Notes + +- Filament v5 requires Livewire v4.0+; this repo already satisfies that requirement. +- Laravel 11+ panel providers are registered in `bootstrap/providers.php`; this feature does not add or change panel providers. +- No new Filament Resource or Global Search surface is planned, so global search behavior is unchanged. +- No new assets are registered. Deployment keeps the existing Filament asset step (`cd apps/platform && php artisan filament:assets`) when other asset-bearing changes require it. +- Readiness freshness for this slice reuses existing signals only: connection-change / selected-connection mismatch from onboarding lifecycle state plus stored permission freshness from `TenantRequiredPermissionsViewModelBuilder`. + +## Implementation proof — 2026-04-25 + +- Targeted fast-feedback validation passed: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php` — 55 tests, 212 assertions. +- Formatting passed: `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. +- Guardrail close-out: keep. No provider-boundary drift, no lane-cost escalation, no new assets, no new persistence, and no follow-up spec required. +- Explicit defers retained: numeric completion score and cross-surface readiness reuse. diff --git a/specs/240-tenant-onboarding-readiness/research.md b/specs/240-tenant-onboarding-readiness/research.md new file mode 100644 index 00000000..40b3708f --- /dev/null +++ b/specs/240-tenant-onboarding-readiness/research.md @@ -0,0 +1,72 @@ +# Research: Self-Service Tenant Onboarding & Connection Readiness + +**Branch**: `240-tenant-onboarding-readiness` +**Date**: 2026-04-25 +**Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/spec.md` +**Plan**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/plan.md` + +## Decisions + +### D-001 — Keep readiness derived inside the existing onboarding workflow + +**Decision**: Compose onboarding readiness inside the existing `ManagedTenantOnboardingWizard` and related landing/draft-picker rendering, using current onboarding, provider connection, verification, and permission-posture truth. Do not add an onboarding-readiness table, persisted projection, or new readiness enum. + +**Rationale**: The repo already stores the durable workflow truth in `TenantOnboardingSession`, `ProviderConnection`, `OperationRun`, and existing permission-posture data. `OnboardingLifecycleService` already computes checkpoint and action-required state, while `ProviderConnectionSurfaceSummary` and verification-assist builders already expose the provider and permissions detail needed for an operator-facing summary. + +**Alternatives considered**: +- Add a dedicated onboarding-readiness model or table: rejected because the summary is derived and has no independent lifecycle. +- Introduce a reusable cross-provider onboarding framework: rejected because the current release has one provider and already has sufficient provider-owned seams. + +### D-002 — Reuse the current landing route behavior instead of creating a new onboarding register + +**Decision**: Keep `/admin/onboarding` as the workspace-scoped entry point that either redirects to the single resumable draft or renders the existing multi-draft picker. Add compact readiness snippets to that same picker rather than introducing a second onboarding dashboard or register. + +**Rationale**: `ManagedTenantOnboardingWizard::resolveLandingState()` already treats the landing route and the route-bound draft as one workflow surface. `OnboardingDraftPickerTest` proves that the picker already carries stage, attribution, and resume/view actions. Adding readiness context there keeps the operator in the same workflow instead of splitting decision-making across multiple pages. + +**Alternatives considered**: +- New onboarding-list resource or canonical register: rejected because it would duplicate draft-selection semantics and enlarge scope. +- Landing-only summary without draft-picker enrichment: rejected because operators with multiple drafts still need draft-level readiness to choose the correct draft. + +### D-003 — Reuse existing freshness signals; do not invent a new onboarding freshness policy + +**Decision**: Treat readiness freshness as a composition of existing signals only: +- `OnboardingLifecycleService` connection-change and selected-connection mismatch signals (`connection_recently_updated`, `verification_result_stale`), and +- `TenantRequiredPermissionsViewModelBuilder::deriveFreshness()` for stored permission posture freshness (current repo rule: stale when absent or older than 30 days). + +Do not introduce a second onboarding-specific freshness threshold, new config key, or persisted freshness state in this slice. + +**Rationale**: The spec’s “freshness” requirement can be satisfied by existing repo truth without creating new semantics. The onboarding workflow already downgrades mismatched or changed verification evidence to action-required, while permission posture already carries a timestamp-based freshness rule used by verification-assist detail. + +**Alternatives considered**: +- Add a new configurable verification-run age threshold for onboarding: rejected because there is no existing shared onboarding policy for it, and this slice should not create one. +- Persist freshness state on the onboarding draft: rejected because freshness is a derived presentation concern from existing evidence timestamps and mismatch flags. + +### D-004 — Keep permission and consent diagnostics provider-owned and secondary + +**Decision**: Use `ProviderConnectionSurfaceSummary`, `VerificationAssistViewModelBuilder`, and `TenantRequiredPermissionsViewModelBuilder` to expose provider-specific consent and permission detail as secondary diagnostics beneath a platform-neutral readiness summary. + +**Rationale**: The top-level operator question is provider-neutral: “Is this tenant ready, and what should I do next?” The exact remediation details still need Microsoft-specific wording today, and those seams already exist in the repo. + +**Alternatives considered**: +- Flatten Microsoft-specific permission names into a new platform-core readiness taxonomy: rejected because it would deepen provider coupling in shared UI semantics. +- Hide detailed permission context from onboarding entirely: rejected because the operator still needs precise remediation guidance without opening raw operation data first. + +### D-005 — Preserve shared OperationRun link and start semantics + +**Decision**: Any readiness CTA that opens evidence or reuses verification/bootstrap actions must stay on the current shared OperationRun and Ops-UX paths: `OperationRunLinks`, existing onboarding `tenantlessOperationRunUrl(...)`, `OperationUxPresenter`, and `ProviderOperationStartResultPresenter`. + +**Rationale**: The workflow already starts verification as queued work and already links to canonical operation detail. This feature only changes explanation and action prioritization, not run creation semantics. + +**Alternatives considered**: +- Create onboarding-specific run links or custom queued messaging: rejected because it would violate the shared OperationRun UX contract. +- Add a new readiness “refresh” operation type: rejected because the current verification and bootstrap actions already exist. + +### D-006 — Prove the slice with fast-feedback onboarding and policy coverage only + +**Decision**: Keep validation in fast-feedback by extending existing onboarding feature tests and policy/unit coverage. The primary proof set is `ManagedTenantOnboardingWizardTest`, `OnboardingDraftPickerTest`, `OnboardingDraftAuthorizationTest`, `TenantOnboardingSessionPolicyTest`, and `TenantRequiredPermissionsFreshnessTest`. + +**Rationale**: The workflow is server-driven Filament/Livewire UI with existing fixtures. Feature tests can prove landing behavior, draft rendering, readiness wording, next-action precedence, and 404 vs 403 semantics without adding new browser or heavy-governance families. + +**Alternatives considered**: +- New browser smoke coverage as the primary proof: rejected because the feature is DB-driven and already covered by focused feature tests. +- Unit-only coverage for a new summary builder: rejected because the main risk is integrated workflow rendering and authorization semantics, not pure transformation logic. \ No newline at end of file diff --git a/specs/240-tenant-onboarding-readiness/spec.md b/specs/240-tenant-onboarding-readiness/spec.md new file mode 100644 index 00000000..dcd19e5e --- /dev/null +++ b/specs/240-tenant-onboarding-readiness/spec.md @@ -0,0 +1,297 @@ +# Feature Specification: Self-Service Tenant Onboarding & Connection Readiness + +**Feature Branch**: `[240-tenant-onboarding-readiness]` +**Created**: 2026-04-25 +**Status**: Draft +**Input**: User description: "Promote the roadmap-fit candidate 'Self-Service Tenant Onboarding & Connection Readiness' as a narrow, implementation-ready slice that turns the existing managed-tenant onboarding flow into an operator-facing readiness workflow. The slice should reuse existing onboarding session, provider connection, verification, and checkpoint foundations to show guided setup progress, provider connection health, permission and consent diagnostics, freshness, and concrete next actions before deeper governance workflows begin. It should explicitly reduce founder-led manual onboarding and make the current onboarding state understandable without raw run inspection. Out of scope: CRM/trial pipeline, billing, marketplace/provider expansion, autonomous remediation, or broad new onboarding persistence if existing onboarding/session/provider models are sufficient." + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: Managed-tenant onboarding truth is fragmented across onboarding drafts, provider connection status, verification runs, permission posture, and checkpoint progression, so an operator cannot quickly tell whether a tenant is genuinely ready without founder guidance or raw run inspection. +- **Today's failure**: Onboarding stalls behind generic failure or incomplete-state messaging, operators cannot reliably distinguish missing consent from missing permissions or stale verification, and founder-led walkthroughs fill the product gap. +- **User-visible improvement**: The existing onboarding workflow becomes a guided readiness view that shows setup progress, connection health, permission and consent blockers, freshness, and one concrete next action in the same place the operator resumes onboarding. +- **Smallest enterprise-capable version**: Reuse the current onboarding session, provider connection, verification, permission diagnostics, and checkpoint foundations to add one derived readiness summary inside the existing managed-tenant onboarding workflow, with canonical links to existing evidence and actions. +- **Explicit non-goals**: CRM or trial pipeline work, billing or entitlement changes, provider marketplace expansion, autonomous remediation, a second onboarding persistence model, a generalized multi-provider onboarding framework, or a numeric onboarding completion score for this slice. +- **Permanent complexity imported**: A bounded derived readiness composition for the existing onboarding surface, readiness-specific copy/state mapping on the current wizard, and focused feature tests for readiness, freshness, and authorization; no new persisted entity, capability family, or cross-domain framework. +- **Why now**: This is the first roadmap item in the self-service foundation cluster and directly removes founder-led onboarding work while making later support diagnostic and trial flows smaller and safer. +- **Why not local**: The pain spans onboarding checkpoint state, provider connection status, permission posture, verification freshness, and supporting evidence links; isolated copy changes in one step would preserve drift and false-green risk. +- **Approval class**: Core Enterprise +- **Red flags triggered**: 4 foundation-sounding theme. Defense: this slice is explicitly constrained to current onboarding routes and existing truth, with no new persistence or platform framework. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace +- **Primary Routes**: `/admin/onboarding`, `/admin/onboarding/{onboardingDraft}`, linked `/admin/consent/start` and `/admin/consent/callback` flows, and canonical `/admin/operations/{run}` deep links for supporting evidence +- **Data Ownership**: `TenantOnboardingSession` remains the workspace-owned workflow record; linked `ProviderConnection`, permission/verification truth, and `OperationRun` evidence remain existing tenant-owned or run-owned records; readiness stays derived and non-persistent +- **RBAC**: Workspace membership is mandatory. `Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD` gates view/update of the readiness workflow, `Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CANCEL` gates destructive cancellation, linked-tenant visibility still passes tenant-bound administrative viewability checks, non-members or wrong workspace/tenant scope return 404, and members lacking capability return 403. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: N/A - this slice stays in the workspace onboarding workflow, not a canonical cross-tenant register +- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace membership plus linked-tenant viewability checks continue to deny as not found before any readiness data or supporting operation detail is revealed + +## 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 +- **Interaction class(es)**: status messaging, action links, embedded diagnostics, navigation +- **Systems touched**: managed-tenant onboarding workflow, onboarding lifecycle snapshot/stage resolution, provider connection summary rendering, permission diagnostics, canonical operation links +- **Existing pattern(s) to extend**: onboarding lifecycle snapshot, embedded verification-report and provider-connection summary patterns, canonical operation link helper +- **Shared contract / presenter / builder / renderer to reuse**: `App\Services\Onboarding\OnboardingLifecycleService`, `App\Services\Onboarding\OnboardingDraftStageResolver`, `App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary`, and `App\Support\OperationRunLinks` +- **Why the existing shared path is sufficient or insufficient**: These paths already compute checkpoint progression, verification state, bootstrap summaries, provider connection readiness wording, and canonical operation navigation. The slice only needs to compose them into one operator-facing readiness view. +- **Allowed deviation and why**: Provider-specific Microsoft permission names and consent details may remain inside provider-owned diagnostic detail because exact remediation still depends on the current provider. +- **Consistency impact**: Readiness labels, freshness cues, next-action verbs, and `Open operation`/consent link semantics must stay aligned across onboarding checkpoints and linked diagnostics. +- **Review focus**: Verify that no second readiness taxonomy, local status palette, or page-specific operation-link language appears outside the shared paths above, and that any shared builder change stays additive and behavior-preserving for non-onboarding consumers while onboarding-specific action prioritization remains page-local. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: yes +- **Shared OperationRun UX contract/layer reused**: `App\Support\OperationRunLinks` for canonical tenantless `Open operation` links, the existing onboarding `tenantlessOperationRunUrl(...)` helper, and `App\Services\OperationRunService` as the only owner of run status/outcome transitions for any reused verification/bootstrap actions +- **Delegated start/completion UX behaviors**: canonical `Open operation` link resolution, tenant/workspace-safe operation URL resolution, existing queued-intent behavior for reused verification starts, dedupe-or-blocked messaging for existing verification actions, and terminal lifecycle notifications through the current shared run UX path +- **Local surface-owned behavior that remains**: readiness explanation, freshness wording, and prioritization of the one next action inside the onboarding workflow +- **Queued DB-notification policy**: no new queued or running DB notification is introduced in this slice; any terminal notification remains explicit and central +- **Terminal notification path**: central lifecycle mechanism already used by the underlying run flow +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: yes +- **Boundary classification**: mixed +- **Seams affected**: provider connection descriptors, consent and permission diagnostics, readiness copy, next-action labels, freshness messaging +- **Neutral platform terms preserved or introduced**: provider connection, readiness, diagnostics, next action, freshness, onboarding step +- **Provider-specific semantics retained and why**: Microsoft consent and permission names remain inside contextual diagnostic detail because the operator needs exact remediation instructions for the only supported provider. +- **Why this does not deepen provider coupling accidentally**: No new platform-core enum, persisted state, or canonical taxonomy becomes Microsoft-shaped. Top-level readiness stays a derived composition of existing onboarding and provider connection truth. +- **Follow-up path**: none + +## 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 +not change an operator-facing surface, write `N/A - no operator-facing surface +change` here and do not invent duplicate prose in the downstream surface tables. + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Managed tenant onboarding workflow | yes | Native Filament + shared primitives | status messaging, action links, badges, embedded diagnostics | page, workflow step, derived readiness summary | no | Guided-flow surface; no new route family | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +If this feature adds or materially changes an operator-facing surface, +fill out one row per affected surface. This role is orthogonal to the +Action Surface Class / Surface Type below. Reuse the exact surface names +and classifications from the UI / Surface Guardrail Impact section above. + +| 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 | +|---|---|---|---|---|---|---|---| +| Managed tenant onboarding workflow | Primary Decision Surface | Decide whether onboarding can proceed now or which blocker to resolve first | Current checkpoint, readiness summary, freshness, linked tenant/provider scope, and one primary next action | Full permission diff, provider-specific diagnostic detail, canonical operation detail | Primary because this is the place where the operator resumes or completes onboarding work, not a secondary diagnostic register | Follows identify → connect provider → verify access → bootstrap → activate progression inside one workflow context | Removes the need to open raw operation detail or separate provider screens just to understand what to do next | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface, +fill out one row per affected surface. Declare the broad Action Surface +Class first, then the detailed Surface Type. Keep this table in sync +with the Decision-First Surface Role section above and avoid renaming the +same surface a second time. + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Managed tenant onboarding workflow | Workflow / Guided action entry | Guided onboarding / readiness workflow | Resolve the current blocker or continue to the next checkpoint | In-page readiness section on the current draft route | forbidden | Supporting diagnostics and collection navigation stay secondary within section reveals or footer links | Header-only destructive actions, confirmation-protected | `/admin/onboarding` | `/admin/onboarding/{onboardingDraft}` | Workspace context, linked tenant identity, provider connection summary | Onboarding / Onboarding readiness | Current checkpoint, connection health, permission/consent blocker, freshness, and next action | Guided-workflow exception; this is a wizard surface, not a CRUD resource, but it still keeps one primary decision context | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +If this feature adds a new operator-facing page or materially refactors +one, fill out one row per affected page/surface. The contract MUST show +how one governance case or operator task becomes decidable without +unnecessary cross-page reconstruction. + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Managed tenant onboarding workflow | Workspace operator managing tenant setup | Decide whether onboarding can proceed and execute the next readiness action | Guided workflow | What is blocking this tenant from being ready, and what do I do next? | Checkpoint progress, readiness summary, provider connection health, consent/permission status, freshness of latest evidence, and the primary next action | Full permission diff, provider-specific identifiers, raw verification payloads, and canonical operation detail | workflow checkpoint, readiness outcome, freshness, execution outcome | TenantPilot only for draft progression; Microsoft tenant only when operator follows existing consent or verification actions | Continue onboarding, grant or regrant consent when needed, run or rerun verification when needed, open operation | Cancel onboarding, delete draft | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +Fill this section if the feature introduces any of the following: +- a new source of truth +- a new persisted entity, table, or artifact +- a new abstraction (interface, contract, registry, resolver, strategy, factory, orchestration layer) +- a new enum, status family, reason code family, or lifecycle category +- a new cross-domain UI framework, taxonomy, or classification system + +- **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?**: no +- **Current operator problem**: Operators cannot trust or explain onboarding readiness from the current workflow without opening raw diagnostics or asking the founder. +- **Existing structure is insufficient because**: The current truth exists, but it is spread across workflow state, provider connection state, verification evidence, and permission posture with no single operator-facing readiness composition. +- **Narrowest correct implementation**: Compose existing onboarding lifecycle, provider connection summary, permission diagnostics, and operation evidence inside the current onboarding workflow, with no new persistence or platform-wide framework. +- **Ownership cost**: A small amount of readiness mapping and focused feature-test coverage must be maintained, but there is no new stored truth, capability family, or cross-domain taxonomy. +- **Alternative intentionally rejected**: A new onboarding-readiness table/state machine or generic provider-onboarding framework was rejected as overproduction for the current release. +- **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)* + +For docs-only or template-only changes, state concise `N/A` or `none`. For runtime- or test-affecting work, classification MUST follow the proving purpose of the change rather than the file path or folder name. + +- **Test purpose / classification**: Feature, Unit +- **Validation lane(s)**: fast-feedback +- **Why this classification and these lanes are sufficient**: Livewire/Filament feature tests prove readiness mapping, next-action guidance, retained action behavior, freshness truth, and deny semantics on the onboarding surface, while targeted unit and policy coverage proves reused permission-freshness and authorization seams without browser automation or heavy-governance breadth. +- **New or expanded test families**: extend onboarding feature coverage plus targeted unit and policy coverage for onboarding authorization and reused permission-freshness behavior only +- **Fixture / helper cost impact**: workspace membership, onboarding draft, linked tenant, provider connection, and operation-run states are required; avoid new browser harnesses, new heavy fixtures, or provider mocks beyond the current readiness scenarios +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: standard-native-filament +- **Standard-native relief or required special coverage**: ordinary onboarding feature coverage plus focused policy and freshness regressions only +- **Reviewer handoff**: Confirm that negative authorization still distinguishes 404 vs 403 correctly, retained start and destructive actions keep their existing guardrails, no browser-only proof was added for a server-driven workflow, and proof commands remain limited to onboarding, onboarding-authorization, and freshness-policy coverage. +- **Budget / baseline / trend impact**: none expected beyond ordinary feature-local runtime +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php`; `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`; `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Resume Onboarding With Trustworthy Readiness (Priority: P1) + +An authorized workspace operator opens an onboarding draft and immediately sees the current setup step, whether the tenant is actually ready, what evidence is fresh or stale, and what the next action is without needing to inspect raw operations first. + +**Why this priority**: This is the smallest slice that directly removes founder-led manual walkthroughs and turns the current onboarding state into a self-explanatory workflow. + +**Independent Test**: Seed onboarding drafts in multiple lifecycle states with linked provider connections and verification outcomes, open the onboarding routes, and confirm the workflow shows checkpoint progress, readiness, freshness, and one primary next action. + +**Acceptance Scenarios**: + +1. **Given** an authorized operator opens an existing onboarding draft with a linked provider connection, **When** the workflow loads, **Then** it shows the current checkpoint, readiness summary, freshness, and one primary next action without requiring raw run inspection. +2. **Given** an operator opens the onboarding landing page while resumable drafts already exist, **When** the landing context renders, **Then** it shows enough progress and blocker context to choose the correct draft to resume. + +--- + +### User Story 2 - Diagnose Consent And Permission Blockers (Priority: P2) + +An authorized operator can tell whether onboarding is blocked by missing consent, missing permissions, disabled or unhealthy provider connection state, or an unresolved verification result, and receives explicit guidance for the next corrective action. + +**Why this priority**: Trustworthy blocker classification is the part that prevents false-green readiness and generic error handling from turning into support work. + +**Independent Test**: Seed drafts with missing consent, permission gaps, disabled connections, and blocked verification outcomes, then confirm each scenario shows distinct blocker language and the appropriate next action. + +**Acceptance Scenarios**: + +1. **Given** consent is missing or revoked, **When** the operator opens the readiness workflow, **Then** the blocker is labeled explicitly and the primary action points to the consent follow-up flow rather than generic failure copy. +2. **Given** required permissions are missing or insufficient, **When** the operator opens the readiness workflow, **Then** the operator sees explicit permission diagnostics in provider-owned terms and a concrete remediation path. + +--- + +### User Story 3 - Trust Freshness And Supporting Evidence (Priority: P3) + +An authorized operator can tell whether supporting verification or bootstrap evidence is current enough to trust, and can open the canonical supporting operation when more detail is necessary. + +**Why this priority**: Freshness and evidence linkage prevent the workflow from becoming a false-ready facade while still keeping raw diagnostics secondary. + +**Independent Test**: Seed matching, stale, and mismatched verification runs for the same draft, then confirm the workflow surfaces freshness truth and a single canonical operation link only when evidence exists. + +**Acceptance Scenarios**: + +1. **Given** the latest verification run is stale or tied to a different selected provider connection, **When** the operator opens the workflow, **Then** readiness falls back to a non-ready state with explicit freshness or mismatch guidance instead of reporting ready. +2. **Given** a supporting verification or bootstrap run exists, **When** the operator wants more detail, **Then** the workflow offers one canonical `Open operation` link that respects authorization. + +### Edge Cases + +- An onboarding draft exists before any tenant is linked; the workflow must show identity and connection prerequisites without implying the tenant is ready. +- The latest verification run belongs to a different provider connection than the currently selected one; the workflow must treat it as mismatched evidence and require attention. +- A provider connection was previously healthy but is now disabled or its consent was revoked; the workflow must not keep showing the older ready state. +- Health or verification evidence is older than the accepted freshness window; the workflow must show stale evidence and a rerun or refresh next action. +- An actor still has workspace membership but no longer has linked-tenant entitlement; the workflow must return 404 rather than partial readiness data. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature does not introduce a new Microsoft Graph domain, a new tenant-changing workflow, or a new queued-work family. It reuses existing onboarding, consent, verification, and bootstrap actions. Any reused verification or bootstrap action continues to rely on the current contract-registry-backed provider execution, existing safety gates, tenant isolation, `OperationRun` observability, and onboarding audit events. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The slice is derived-only. It must not add a new persisted readiness model, a new cross-provider onboarding abstraction, or a new canonical readiness state family. Existing onboarding lifecycle, provider consent/verification states, and supporting evidence remain the source of truth. + +**Constitution alignment (XCUT-001):** The slice extends existing onboarding lifecycle composition, provider connection summary rendering, and canonical operation-link paths. No parallel onboarding health language or page-local operation-link system may appear. + +**Constitution alignment (PROV-001):** Top-level readiness language stays platform-neutral, while Microsoft-specific consent and permission details remain contextual and provider-owned. The feature must not turn Microsoft permission or consent semantics into new platform-core truth. + +**Constitution alignment (TEST-GOV-001):** Implementation must stay in fast-feedback feature coverage, keep fixtures opt-in and onboarding-local, and avoid creating a new heavy-governance or browser family for a server-driven readiness workflow. + +**Constitution alignment (OPS-UX):** If the workflow exposes existing verification or bootstrap start actions, those actions must continue to use the existing queued-intent feedback contract, canonical `Open operation` links, central `OperationRunService` status ownership, and no new queued or running DB notifications. + +**Constitution alignment (OPS-UX-START-001):** Any `OperationRun` deep link or reused start action in this workflow must delegate URL resolution and run-link behavior to the shared operation-link path rather than page-local URLs. + +**Constitution alignment (RBAC-UX):** Non-members, wrong-workspace actors, and actors lacking linked-tenant entitlement continue to receive 404. Members missing onboarding capability receive 403 on protected workflow actions. Existing destructive onboarding actions remain confirmation-protected and server-authorized. + +**Constitution alignment (BADGE-001):** Consent and verification badges remain driven by the central badge domains already used by provider connection summaries; no page-local color or label mapping is introduced. + +**Constitution alignment (UI-FIL-001):** The workflow continues to use native Filament sections, cards, badges, and shared primitives. The slice may reorganize emphasis, but it must not replace the workflow with custom fake-native controls. + +**Constitution alignment (UI-NAMING-001):** Primary operator-facing labels remain grounded in onboarding vocabulary such as `Continue onboarding`, `Grant consent`, `Run verification`, `Open operation`, `Cancel onboarding`, and `Delete draft`. Implementation-first language stays in secondary diagnostics only. + +**Constitution alignment (DECIDE-001):** The onboarding workflow remains the one primary decision surface for onboarding readiness. It must present enough truth to decide the next action without forcing cross-page reconstruction from raw operations. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** This guided workflow remains a bounded exception to list/detail table rules, but it must still keep one primary next action, preserve header-action discipline, keep destructive actions separated by risk, and avoid mixed catch-all action groups. + +**Constitution alignment (ACTSURF-001 - action hierarchy):** Navigation, mutation, and destructive onboarding actions remain visibly separated. Secondary diagnostics do not compete with the primary next action. + +**Constitution alignment (OPSURF-001):** The default-visible content must stay operator-first, with raw provider detail and full operation diagnostics deliberately secondary. Mutation scope remains explicit when the workflow points to provider actions. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Any new readiness wording remains a thin adapter over existing onboarding, provider connection, and verification truth. Tests must prove business consequences such as blocker classification, freshness, and authorization rather than a thin presentation layer alone. + +**Constitution alignment (Filament Action Surfaces):** The managed-tenant onboarding page remains an explicit guided-workflow exception rather than a CRUD resource. Action inventory must still preserve one primary next action, avoid redundant inspect affordances, and keep destructive actions gated and confirmation-protected. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** The onboarding page continues to use native sections and cards, puts default-visible readiness truth ahead of diagnostics, and keeps empty/start states focused on one clear CTA. + +### Functional Requirements + +- **FR-001**: The system MUST compose onboarding readiness from the existing onboarding session, provider connection state, verification evidence, permission diagnostics, and checkpoint progression instead of introducing a second onboarding-readiness persistence model. +- **FR-002**: The system MUST show the current checkpoint, completed progress, derived readiness summary, freshness, and one primary next action on the existing onboarding landing surfaces: the route-bound draft at `/admin/onboarding/{onboardingDraft}`, and the `/admin/onboarding` landing experience when multiple resumable drafts exist. If `/admin/onboarding` resolves directly to a single resumable draft, the existing redirect behavior remains in place. +- **FR-003**: The workflow MUST distinguish operator-visible conditions for not started, in progress, blocked, needs attention, stale evidence, and ready-to-proceed scenarios instead of collapsing them into generic incomplete-state copy. +- **FR-004**: When a linked provider connection exists, the workflow MUST reuse the existing provider connection summary path to show consent state, verification state, contextual identity details, and readiness wording. +- **FR-005**: Missing or insufficient permissions MUST produce explicit provider-owned diagnostics and a concrete next action, while top-level readiness vocabulary remains platform-neutral. +- **FR-006**: The workflow MUST display freshness for the latest verification or health evidence using the existing permission freshness threshold (currently 30 days) plus selected-provider-connection mismatch signals, and MUST mark stale or mismatched evidence as needing attention rather than ready. +- **FR-007**: Supporting verification or bootstrap evidence MUST deep-link through the canonical tenantless operation route and MUST expose only one primary `Open operation` affordance per supporting record. +- **FR-008**: If the workflow exposes existing start or rerun actions for consent, verification, or bootstrap follow-up, those actions MUST preserve existing authorization, audit, dedupe, and shared Ops-UX behavior and MUST NOT create new run-start paths. +- **FR-009**: Non-members, wrong-workspace actors, and actors without linked-tenant entitlement MUST receive 404 for readiness routes and supporting evidence routes; entitled members lacking the required onboarding capability MUST receive 403 on protected actions. +- **FR-010**: Existing destructive onboarding actions retained in the workflow, including cancel or delete draft behavior, MUST remain confirmation-protected and capability-gated, and this slice MUST NOT introduce new destructive behavior. +- **FR-011**: The workflow MUST reuse existing onboarding lifecycle and verification truth and MUST NOT introduce a new persisted readiness enum, new onboarding table, or generalized provider-onboarding framework. +- **FR-012**: The readiness outcome and next-action summary MUST remain structured as derived onboarding truth so later support diagnostic and trial/demo work can consume the same underlying composition, but cross-surface reuse beyond onboarding is explicitly deferred from this slice and MUST NOT introduce a new shared abstraction or second source of truth now. +- **FR-013**: Microsoft-specific consent and permission details MUST stay contextual and secondary to the operator-first readiness summary. +- **FR-014**: When no supporting verification or diagnostic evidence exists yet, the workflow MUST state that the check has not run yet and point to the correct next action rather than exposing empty diagnostics. +- **FR-015**: The workflow MUST continue to use native Filament sections, cards, and shared badge semantics instead of page-local status cards or custom color logic. + +## UI Action Matrix *(mandatory when Filament is changed)* + +If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below. + +For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?), +RBAC gating (capability + enforcement helper), whether the mutation writes an audit log, and any exemption or exception used. + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Managed tenant onboarding workflow | `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` | Existing `Start onboarding` and draft-selection affordances remain; readiness-specific header additions stay non-destructive | N/A - guided workflow page, not a list table | Draft-picker context may expose `Resume onboarding`; readiness panels expose one primary `Open operation` link when evidence exists | none | `Start onboarding` on empty/start state | Existing `Cancel onboarding` and `Delete draft` remain in header placements by risk; `Open operation` and `Open tenant` stay contextual in-section links when legitimate | Existing wizard next/back/save/cancel behavior remains | yes for existing draft lifecycle mutations and reused start actions | Guided-workflow exception. Action Surface Contract remains satisfied through one primary next action in-page; destructive actions stay confirmation-protected and capability-gated. | + +### Key Entities *(include if feature involves data)* + +- **Tenant Onboarding Session**: The existing workspace-scoped workflow record that tracks onboarding checkpoint progression, resumability, and optional linkage to a tenant. +- **Provider Connection**: The existing tenant-owned provider access record whose consent state, verification state, target-scope summary, and readiness wording inform onboarding readiness. +- **Onboarding Readiness Summary**: A derived, non-persistent operator view composed from onboarding lifecycle, provider connection truth, permission diagnostics, freshness, supporting operation evidence, and the next recommended action. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In each in-scope onboarding scenario, an authorized operator can identify the current blocker and next action from the onboarding workflow in 30 seconds or less without opening raw operation detail first. +- **SC-002**: In scripted scenarios covering missing consent, permission gaps, disabled or unhealthy provider connection state, stale verification, verification blocked, and ready-for-activation, 100% of rendered states show distinct operator-readable outcomes rather than generic incomplete-state messaging. +- **SC-003**: When supporting verification or bootstrap evidence exists, the operator can reach the canonical supporting operation from the onboarding workflow in one interaction. +- **SC-004**: In negative authorization scenarios for wrong workspace, non-member, or non-entitled linked-tenant access, 100% of requests hide onboarding readiness data behind 404 responses, while capability-denied members receive 403 on protected actions. + +## Assumptions + +- Existing onboarding session, provider connection, verification, and permission-posture foundations are sufficient for the first readiness slice. +- The current provider remains Microsoft-first, but provider-specific details stay contextual rather than becoming new platform-core truth. +- Later support diagnostic and trial/demo flows are expected to consume the same derived readiness truth, but any reuse beyond onboarding is deferred from this slice and must not force a new abstraction or second onboarding state model now. diff --git a/specs/240-tenant-onboarding-readiness/tasks.md b/specs/240-tenant-onboarding-readiness/tasks.md new file mode 100644 index 00000000..aa9a83b0 --- /dev/null +++ b/specs/240-tenant-onboarding-readiness/tasks.md @@ -0,0 +1,194 @@ +--- + +description: "Task list for feature implementation" +--- + +# Tasks: Self-Service Tenant Onboarding & Connection Readiness + +**Input**: Design documents from `/specs/240-tenant-onboarding-readiness/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md, checklists/requirements.md + +**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in the `fast-feedback` lane using the onboarding, authorization, policy, and freshness suites listed in `specs/240-tenant-onboarding-readiness/quickstart.md`. + +## Test Governance Notes + +- Lane assignment: `fast-feedback` is the narrowest sufficient proof for this slice. +- No new browser or heavy-governance family should be introduced; keep any new fixtures onboarding-local and cheap by default. +- Surface profile: `standard-native-filament` relief applies unless implementation widens the existing wizard/picker surface beyond the current plan. +- If implementation leaves a bounded provider-boundary hotspot or widens lane cost, record that outcome in `specs/240-tenant-onboarding-readiness/plan.md` and `specs/240-tenant-onboarding-readiness/quickstart.md` before merge. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Confirm the current onboarding baseline and pin the feature inputs before runtime edits begin. + +- [X] T001 Run the baseline fast-feedback onboarding suites listed for this feature in specs/240-tenant-onboarding-readiness/quickstart.md (specs/240-tenant-onboarding-readiness/quickstart.md, apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php, apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php, apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php, apps/platform/tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php, apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php) +- [X] T002 [P] Review the derived-only readiness contract, next-action precedence, and provider-boundary constraints before editing runtime files (specs/240-tenant-onboarding-readiness/spec.md, specs/240-tenant-onboarding-readiness/plan.md, specs/240-tenant-onboarding-readiness/research.md, specs/240-tenant-onboarding-readiness/data-model.md, specs/240-tenant-onboarding-readiness/contracts/onboarding-readiness.openapi.yaml) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish the shared derived-readiness seams that every story depends on without adding persistence, enums, or a new onboarding framework. + +**Critical**: No user story work should start until this phase is complete. + +- [X] T003 Create a presentation-only readiness payload shell on the existing onboarding page without adding new persistence or readiness enums (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php) +- [X] T004 [P] Thread route-bound authorization and canonical operation-link seams through that readiness payload so later story work stays inside existing workspace and tenant boundaries (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php, apps/platform/app/Support/OperationRunLinks.php) +- [X] T005 [P] Reuse the existing provider summary, verification assist, and permission freshness builders as the only readiness inputs so provider-specific detail stays secondary and contextual, and keep any shared-builder extension additive and behavior-preserving for non-onboarding consumers (apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php, apps/platform/app/Support/Verification/VerificationAssistViewModelBuilder.php, apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php) + +**Checkpoint**: Foundation ready - the onboarding wizard has one derived-readiness seam and later stories can extend it without creating a second source of truth. + +--- + +## Phase 3: User Story 1 - Resume Onboarding With Trustworthy Readiness (Priority: P1) + +**Goal**: An authorized workspace operator can open onboarding and immediately understand checkpoint progress, current readiness, freshness, and the one next action on both the landing route and the route-bound draft. + +**Independent Test**: Seed resumable drafts in different lifecycle states, open `/admin/onboarding` and `/admin/onboarding/{onboardingDraft}`, and confirm the workflow shows progress, readiness, freshness, and one primary next action without raw operation inspection. + +### Tests for User Story 1 + +- [X] T006 [P] [US1] Add route-bound readiness feature coverage for checkpoint progress, readiness summary, freshness cues, explicit "check has not run yet" guidance, and one primary next action in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php) +- [X] T007 [P] [US1] Add landing-route feature coverage for compact readiness snippets on multiple drafts while preserving the existing single-draft redirect behavior in apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php (apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php) +- [X] T008 [P] [US1] Extend readiness-route authorization coverage so non-members and wrong-workspace actors stay 404 while capability-denied members stay 403 in apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php (apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php) + +### Implementation for User Story 1 + +- [X] T009 [US1] Render the derived readiness summary, checkpoint progress, freshness note, explicit "check has not run yet" guidance, and primary next-action slot on the route-bound onboarding draft view in apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php) +- [X] T010 [US1] Surface compact readiness context on the multi-draft picker without changing the landing route contract, resume flow, or view-summary affordances in apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php) +- [X] T011 [US1] Keep draft and landing progress labels aligned through the shared lifecycle and stage resolvers instead of page-local state maps in apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php and apps/platform/app/Services/Onboarding/OnboardingDraftStageResolver.php (apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php, apps/platform/app/Services/Onboarding/OnboardingDraftStageResolver.php) + +**Checkpoint**: User Story 1 is independently functional when operators can choose or resume the correct draft from readiness context alone. + +--- + +## Phase 4: User Story 2 - Diagnose Consent And Permission Blockers (Priority: P2) + +**Goal**: An authorized operator can tell whether onboarding is blocked by consent, permissions, or unhealthy provider connection state, and gets one concrete remediation path without introducing provider-specific top-level taxonomy. + +**Independent Test**: Seed drafts with missing consent, revoked consent, permission gaps, and disabled connection states, then confirm each scenario renders distinct blocker language and the correct next action. + +### Tests for User Story 2 + +- [X] T012 [P] [US2] Add blocker-matrix feature coverage for missing consent, revoked consent, disabled connection, and blocked verification scenarios in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php) +- [X] T013 [P] [US2] Add feature coverage proving permission-gap diagnostics stay provider-owned while the top-level readiness summary stays platform-neutral in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php) +- [X] T014 [P] [US2] Extend onboarding capability and linked-tenant entitlement policy coverage used by readiness blocker actions in apps/platform/tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php (apps/platform/tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php) + +### Implementation for User Story 2 + +- [X] T015 [US2] Reuse provider connection summary state to classify consent and connection-health blockers without page-local badge or color mappings, and preserve current non-onboarding summary behavior unless an additive onboarding field is strictly required, in apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php and apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php, apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php) +- [X] T016 [US2] Reuse verification-assist and required-permissions builders to expose provider-owned permission diagnostics, blocked-verification guidance, and remediation links inside the onboarding workflow, keeping onboarding-specific action prioritization local and preserving existing non-onboarding outputs unless an additive field is strictly required, in apps/platform/app/Support/Verification/VerificationAssistViewModelBuilder.php, apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php, and apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Support/Verification/VerificationAssistViewModelBuilder.php, apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php, apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php) +- [X] T017 [US2] Enforce next-action precedence for consent, permission, and blocked-verification blockers while keeping top-level readiness wording provider-neutral in apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php) + +**Checkpoint**: User Story 2 is independently functional when blocker classification is explicit and remediation guidance is concrete without leaking Microsoft-specific terms into the top-level readiness contract. + +--- + +## Phase 5: User Story 3 - Trust Freshness And Supporting Evidence (Priority: P3) + +**Goal**: An authorized operator can tell whether verification evidence is fresh enough to trust and can open the canonical supporting operation when deeper detail is required. + +**Independent Test**: Seed matching, stale, and mismatched verification evidence for the same draft, then confirm readiness downgrades appropriately and only canonical `Open operation` links are rendered when evidence exists. + +### Tests for User Story 3 + +- [X] T018 [P] [US3] Add feature coverage for stale evidence, selected-connection mismatch, and canonical `Open operation` link rendering on the route-bound draft in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php) +- [X] T019 [P] [US3] Add landing-picker feature coverage for stale or mismatched freshness cues across multiple drafts in apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php (apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php) +- [X] T020 [P] [US3] Extend freshness boundary coverage for missing and existing 30-day permission-threshold edge refresh timestamps used by readiness in apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php (apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php) + +### Implementation for User Story 3 + +- [X] T021 [US3] Reuse connection-change and selected-connection-mismatch signals to downgrade readiness and surface freshness guidance on the onboarding page in apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php and apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php, apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php) +- [X] T022 [US3] Route supporting verification and bootstrap evidence through canonical tenantless operation links only in apps/platform/app/Support/OperationRunLinks.php and apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Support/OperationRunLinks.php, apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php) +- [X] T023 [US3] Surface stale and mismatched evidence state on both the route-bound draft and the landing picker without adding persisted readiness flags in apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php) + +**Checkpoint**: User Story 3 is independently functional when stale or mismatched evidence prevents false-ready UI and canonical evidence links remain available for legitimate detail views. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation, formatting, and feature-local close-out for this readiness slice. + +- [X] T024 [P] Add retained-action regression coverage proving reused consent, verification, and bootstrap start or rerun affordances still preserve shared Ops-UX, dedupe, canonical `Open operation` links, and audit behavior in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php) +- [X] T025 [P] Add retained destructive-action regression coverage proving cancel or delete draft affordances remain confirmation-protected and capability-gated in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php and apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php, apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php) +- [X] T026 [P] Run Laravel Pint on touched PHP files through Sail before merge (apps/platform/vendor/bin/sail) +- [X] T027 Run the targeted fast-feedback validation suite listed in specs/240-tenant-onboarding-readiness/quickstart.md after implementation is complete (specs/240-tenant-onboarding-readiness/quickstart.md) +- [X] T028 Record the final guardrail close-out, fast-feedback proof result, and any `document-in-feature` versus `follow-up-spec` decision for remaining provider-boundary or lane-cost notes in specs/240-tenant-onboarding-readiness/plan.md and specs/240-tenant-onboarding-readiness/quickstart.md, including the explicit defer of numeric completion score and cross-surface readiness reuse (specs/240-tenant-onboarding-readiness/plan.md, specs/240-tenant-onboarding-readiness/quickstart.md) + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Phase 1 (Setup) starts immediately. +- Phase 2 (Foundational) depends on Phase 1 and blocks all user stories. +- Phase 3 (US1) depends on Phase 2 and establishes the MVP readiness surface. +- Phase 4 (US2) depends on Phase 2 and should follow US1 in practice because both stories change the same wizard and wizard test suite. +- Phase 5 (US3) depends on Phase 2 and is safest after US1 because freshness and evidence rendering extend the same readiness payload. +- Phase 6 (Polish) depends on every implemented story. + +### User Story Dependencies + +- US1 is the MVP and the first independently shippable increment. +- US2 reuses the US1 readiness surface but remains independently testable through blocker-specific scenarios. +- US3 reuses the same readiness surface and remains independently testable through freshness and evidence scenarios. + +### Within Each User Story + +- Write the listed Pest coverage first and ensure it fails before implementation. +- Complete service or builder changes before the final wizard rendering pass when both are required. +- Re-run the narrowest affected fast-feedback suite after each story checkpoint before moving to the next story. + +--- + +## Parallel Opportunities + +### Phase 1 + +- T001 and T002 can run in parallel if one person captures the baseline while another cross-checks the feature artifacts. + +### Phase 2 + +- T004 and T005 can run in parallel after T003 defines the readiness payload shell. + +### User Story 1 + +- T006, T007, and T008 can run in parallel. +- T009 and T011 can overlap once the shared readiness payload from Phase 2 is in place. + +### User Story 2 + +- T012, T013, and T014 can run in parallel. +- T015 and T016 can overlap before T017 finalizes next-action precedence. + +### User Story 3 + +- T018, T019, and T020 can run in parallel. +- T021 and T022 can overlap before T023 finalizes the shared stale-evidence presentation. + +--- + +## Implementation Strategy + +### MVP First + +1. Complete Phase 1. +2. Complete Phase 2. +3. Complete Phase 3 (US1). +4. Re-run the targeted US1 fast-feedback suites and stop for review. + +### Incremental Delivery + +1. Deliver US1 to make onboarding state understandable on landing and draft routes. +2. Add US2 to classify consent and permission blockers without broadening the provider boundary. +3. Add US3 to harden freshness and evidence trust so the readiness surface cannot go false-green. + +### Team Strategy + +1. Finish Phase 2 together before splitting work. +2. Parallelize test authoring inside each story. +3. Sequence wizard-file merges story-by-story because all three stories converge on apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php and its onboarding feature suites. -- 2.45.2 From 17d3ca8313f9c1143cc4d8d14f6fdda036d457ca Mon Sep 17 00:00:00 2001 From: ahmido Date: Sat, 25 Apr 2026 23:32:30 +0000 Subject: [PATCH 15/36] feat(support-diagnostics): guardrail refactor and UI polish (agent) (#278) Implements support diagnostics bundle, moves audit writes to action mountUsing to avoid side-effects during render, replaces custom slide-over with Filament-native schema, updates tests and adds spec docs. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/278 --- .github/agents/copilot-instructions.md | 4 +- .../TenantlessOperationRunViewer.php | 123 +++ .../app/Filament/Pages/TenantDashboard.php | 112 +++ .../Services/Audit/WorkspaceAuditLogger.php | 46 + .../app/Services/Auth/RoleCapabilityMap.php | 3 + .../app/Support/Audit/AuditActionId.php | 4 + .../app/Support/Auth/Capabilities.php | 3 + .../app/Support/RedactionIntegrity.php | 29 + .../SupportDiagnosticBundleBuilder.php | 942 ++++++++++++++++++ .../support-diagnostic-bundle.blade.php | 162 +++ ...perationRunSupportDiagnosticActionTest.php | 192 ++++ .../SupportDiagnosticAuditTest.php | 186 ++++ .../SupportDiagnosticAuthorizationTest.php | 115 +++ .../TenantSupportDiagnosticActionTest.php | 186 ++++ .../SupportDiagnosticBundleBuilderTest.php | 128 +++ .../SupportDiagnosticBundleRedactionTest.php | 111 +++ docs/product/spec-candidates.md | 64 +- .../checklists/requirements.md | 35 + .../support-diagnostics.openapi.yaml | 284 ++++++ .../241-support-diagnostic-pack/data-model.md | 294 ++++++ specs/241-support-diagnostic-pack/plan.md | 214 ++++ .../241-support-diagnostic-pack/quickstart.md | 46 + specs/241-support-diagnostic-pack/research.md | 140 +++ specs/241-support-diagnostic-pack/spec.md | 297 ++++++ specs/241-support-diagnostic-pack/tasks.md | 194 ++++ 25 files changed, 3866 insertions(+), 48 deletions(-) create mode 100644 apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php create mode 100644 apps/platform/resources/views/filament/modals/support-diagnostic-bundle.blade.php create mode 100644 apps/platform/tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php create mode 100644 apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php create mode 100644 apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php create mode 100644 apps/platform/tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php create mode 100644 apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php create mode 100644 apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php create mode 100644 specs/241-support-diagnostic-pack/checklists/requirements.md create mode 100644 specs/241-support-diagnostic-pack/contracts/support-diagnostics.openapi.yaml create mode 100644 specs/241-support-diagnostic-pack/data-model.md create mode 100644 specs/241-support-diagnostic-pack/plan.md create mode 100644 specs/241-support-diagnostic-pack/quickstart.md create mode 100644 specs/241-support-diagnostic-pack/research.md create mode 100644 specs/241-support-diagnostic-pack/spec.md create mode 100644 specs/241-support-diagnostic-pack/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index bac85be0..eb417e9e 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -258,6 +258,8 @@ ## Active Technologies - PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables (239-canonical-operation-type-source-of-truth) - PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers (240-tenant-onboarding-readiness) - PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned (240-tenant-onboarding-readiness) +- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` (241-support-diagnostic-pack) +- PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack) - PHP 8.4.15 (feat/005-bulk-operations) @@ -292,9 +294,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` - 240-tenant-onboarding-readiness: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers - 239-canonical-operation-type-source-of-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4 -- 238-provider-identity-target-scope: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4 ### Pre-production compatibility check diff --git a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index 0afd761f..4bc07e48 100644 --- a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -8,6 +8,7 @@ use App\Models\OperationRun; use App\Models\Tenant; use App\Models\User; +use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Auth\CapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Baselines\BaselineEvidenceCaptureResumeService; @@ -25,6 +26,8 @@ use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\RedactionIntegrity; use App\Support\RestoreSafety\RestoreSafetyCopy; +use App\Support\Rbac\UiEnforcement; +use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder; use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantOperabilityQuestion; @@ -39,6 +42,7 @@ use Filament\Pages\Page; use Filament\Schemas\Components\EmbeddedSchema; use Filament\Schemas\Schema; +use Illuminate\Contracts\View\View; use Illuminate\Contracts\Support\Htmlable; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Str; @@ -81,6 +85,11 @@ public function getTitle(): string|Htmlable */ public ?array $navigationContextPayload = null; + /** + * @var list + */ + public array $supportDiagnosticsAuditKeys = []; + /** * @return array */ @@ -130,6 +139,10 @@ protected function getHeaderActions(): array ? OperationRunLinks::tenantlessView($this->run, $navigationContext) : OperationRunLinks::index()); + if (isset($this->run)) { + $actions[] = $this->openSupportDiagnosticsAction(); + } + if (! isset($this->run)) { return $actions; } @@ -208,6 +221,116 @@ public function monitoringDetailSummary(): array ]; } + private function openSupportDiagnosticsAction(): Action + { + $action = Action::make('openSupportDiagnostics') + ->label('Open support diagnostics') + ->icon('heroicon-o-lifebuoy') + ->iconButton() + ->tooltip('Open support diagnostics') + ->color('gray') + ->record($this->run) + ->modal() + ->slideOver() + ->stickyModalHeader() + ->modalHeading('Support diagnostics') + ->modalDescription('Redacted operation context from existing records.') + ->modalSubmitAction(false) + ->modalCancelAction(fn (Action $action): Action => $action->label('Close')) + ->mountUsing(function (): void { + $this->auditOperationSupportDiagnosticsOpen(); + }) + ->modalContent(fn (): View => view('filament.modals.support-diagnostic-bundle', [ + 'bundle' => $this->operationRunSupportDiagnosticBundle(), + ])); + + return UiEnforcement::forAction($action) + ->requireCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW) + ->apply(); + } + + /** + * @return array + */ + public function operationRunSupportDiagnosticBundle(): array + { + $user = auth()->user(); + $tenant = $this->supportDiagnosticsTenant(); + + if (! $user instanceof User || ! $tenant instanceof Tenant) { + abort(404); + } + + $resolver = app(CapabilityResolver::class); + + if (! $resolver->isMember($user, $tenant)) { + abort(404); + } + + if (! $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)) { + abort(403); + } + + return app(SupportDiagnosticBundleBuilder::class)->forOperationRun($this->run, $user); + } + + private function auditOperationSupportDiagnosticsOpen(): void + { + $user = auth()->user(); + $tenant = $this->supportDiagnosticsTenant(); + + if (! $user instanceof User || ! $tenant instanceof Tenant) { + abort(404); + } + + $this->recordSupportDiagnosticsOpened( + tenant: $tenant, + bundle: $this->operationRunSupportDiagnosticBundle(), + user: $user, + ); + } + + private function supportDiagnosticsTenant(): ?Tenant + { + if (! isset($this->run)) { + return null; + } + + $tenant = $this->run->tenant; + + if ($tenant instanceof Tenant) { + return $tenant; + } + + return $this->run->loadMissing('tenant')->tenant; + } + + /** + * @param array $bundle + */ + private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, User $user): void + { + if (! isset($this->run)) { + return; + } + + $auditKey = 'operation:'.$this->run->getKey(); + + if (in_array($auditKey, $this->supportDiagnosticsAuditKeys, true)) { + return; + } + + app(WorkspaceAuditLogger::class)->logSupportDiagnosticsOpened( + tenant: $tenant, + contextType: 'operation_run', + bundle: $bundle, + actor: $user, + operationRun: $this->run, + ); + + $this->supportDiagnosticsAuditKeys[] = $auditKey; + } + public function mount(OperationRun $run): void { $user = auth()->user(); diff --git a/apps/platform/app/Filament/Pages/TenantDashboard.php b/apps/platform/app/Filament/Pages/TenantDashboard.php index 19c97490..2b6817f6 100644 --- a/apps/platform/app/Filament/Pages/TenantDashboard.php +++ b/apps/platform/app/Filament/Pages/TenantDashboard.php @@ -11,13 +11,28 @@ use App\Filament\Widgets\Dashboard\RecentDriftFindings; use App\Filament\Widgets\Dashboard\RecentOperations; use App\Filament\Widgets\Dashboard\RecoveryReadiness; +use App\Models\Tenant; +use App\Models\User; +use App\Services\Audit\WorkspaceAuditLogger; +use App\Services\Auth\CapabilityResolver; +use App\Support\Auth\Capabilities; +use App\Support\Rbac\UiEnforcement; +use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder; +use Filament\Actions\Action; +use Filament\Facades\Filament; use Filament\Pages\Dashboard; use Filament\Widgets\Widget; use Filament\Widgets\WidgetConfiguration; +use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Model; class TenantDashboard extends Dashboard { + /** + * @var list + */ + public array $supportDiagnosticsAuditKeys = []; + /** * @param array $parameters */ @@ -46,4 +61,101 @@ public function getColumns(): int|array { return 2; } + + /** + * @return array + */ + protected function getHeaderActions(): array + { + return [ + $this->openSupportDiagnosticsAction(), + ]; + } + + private function openSupportDiagnosticsAction(): Action + { + $action = Action::make('openSupportDiagnostics') + ->label('Open support diagnostics') + ->icon('heroicon-o-lifebuoy') + ->color('gray') + ->modal() + ->slideOver() + ->stickyModalHeader() + ->modalHeading('Support diagnostics') + ->modalDescription('Redacted tenant context from existing records.') + ->modalSubmitAction(false) + ->modalCancelAction(fn (Action $action): Action => $action->label('Close')) + ->mountUsing(function (): void { + $this->auditTenantSupportDiagnosticsOpen(); + }) + ->modalContent(fn (): View => view('filament.modals.support-diagnostic-bundle', [ + 'bundle' => $this->tenantSupportDiagnosticBundle(), + ])); + + return UiEnforcement::forAction($action) + ->requireCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW) + ->apply(); + } + + /** + * @return array + */ + public function tenantSupportDiagnosticBundle(): array + { + $user = auth()->user(); + $tenant = Filament::getTenant(); + + if (! $user instanceof User || ! $tenant instanceof Tenant) { + abort(404); + } + + $resolver = app(CapabilityResolver::class); + + if (! $resolver->isMember($user, $tenant)) { + abort(404); + } + + if (! $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)) { + abort(403); + } + + return app(SupportDiagnosticBundleBuilder::class)->forTenant($tenant, $user); + } + + private function auditTenantSupportDiagnosticsOpen(): void + { + $user = auth()->user(); + $tenant = Filament::getTenant(); + + if (! $user instanceof User || ! $tenant instanceof Tenant) { + abort(404); + } + + $this->recordSupportDiagnosticsOpened( + tenant: $tenant, + bundle: $this->tenantSupportDiagnosticBundle(), + user: $user, + ); + } + + /** + * @param array $bundle + */ + private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, User $user): void + { + $auditKey = 'tenant:'.$tenant->getKey(); + + if (in_array($auditKey, $this->supportDiagnosticsAuditKeys, true)) { + return; + } + + app(WorkspaceAuditLogger::class)->logSupportDiagnosticsOpened( + tenant: $tenant, + contextType: 'tenant', + bundle: $bundle, + actor: $user, + ); + + $this->supportDiagnosticsAuditKeys[] = $auditKey; + } } diff --git a/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php b/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php index aa7c685e..71bd480e 100644 --- a/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php +++ b/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php @@ -5,6 +5,7 @@ namespace App\Services\Audit; use App\Models\Tenant; +use App\Models\OperationRun; use App\Models\User; use App\Models\Workspace; use App\Support\Audit\AuditActionId; @@ -87,4 +88,49 @@ public function logTenantLifecycleAction( tenant: $tenant, ); } + + /** + * @param array $bundle + */ + public function logSupportDiagnosticsOpened( + Tenant $tenant, + string $contextType, + array $bundle, + ?User $actor = null, + ?OperationRun $operationRun = null, + ): \App\Models\AuditLog { + $sectionCount = is_array($bundle['sections'] ?? null) ? count($bundle['sections']) : 0; + $referenceCount = collect($bundle['sections'] ?? []) + ->sum(static fn (mixed $section): int => is_array($section) && is_array($section['references'] ?? null) + ? count($section['references']) + : 0); + + return $this->log( + workspace: $tenant->workspace, + action: AuditActionId::SupportDiagnosticsOpened, + context: [ + 'context_type' => $contextType, + 'redaction_mode' => 'default_redacted', + 'section_count' => $sectionCount, + 'reference_count' => $referenceCount, + 'primary_context_id' => $operationRun instanceof OperationRun + ? (string) $operationRun->getKey() + : (string) $tenant->getKey(), + ], + actor: $actor, + status: 'success', + resourceType: 'support_diagnostic_bundle', + resourceId: $operationRun instanceof OperationRun + ? 'operation_run:'.$operationRun->getKey() + : 'tenant:'.$tenant->getKey(), + targetLabel: $operationRun instanceof OperationRun + ? 'Support diagnostics for operation #'.$operationRun->getKey() + : 'Support diagnostics for '.$tenant->name, + summary: $operationRun instanceof OperationRun + ? 'Support diagnostics opened for operation #'.$operationRun->getKey() + : 'Support diagnostics opened for '.$tenant->name, + operationRunId: $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null, + tenant: $tenant, + ); + } } diff --git a/apps/platform/app/Services/Auth/RoleCapabilityMap.php b/apps/platform/app/Services/Auth/RoleCapabilityMap.php index c81993b4..c13d9ac5 100644 --- a/apps/platform/app/Services/Auth/RoleCapabilityMap.php +++ b/apps/platform/app/Services/Auth/RoleCapabilityMap.php @@ -19,6 +19,7 @@ class RoleCapabilityMap Capabilities::TENANT_MANAGE, Capabilities::TENANT_DELETE, Capabilities::TENANT_SYNC, + Capabilities::SUPPORT_DIAGNOSTICS_VIEW, Capabilities::TENANT_INVENTORY_SYNC_RUN, Capabilities::TENANT_FINDINGS_VIEW, Capabilities::TENANT_FINDINGS_TRIAGE, @@ -63,6 +64,7 @@ class RoleCapabilityMap Capabilities::TENANT_VIEW, Capabilities::TENANT_MANAGE, Capabilities::TENANT_SYNC, + Capabilities::SUPPORT_DIAGNOSTICS_VIEW, Capabilities::TENANT_INVENTORY_SYNC_RUN, Capabilities::TENANT_FINDINGS_VIEW, Capabilities::TENANT_FINDINGS_TRIAGE, @@ -103,6 +105,7 @@ class RoleCapabilityMap TenantRole::Operator->value => [ Capabilities::TENANT_VIEW, Capabilities::TENANT_SYNC, + Capabilities::SUPPORT_DIAGNOSTICS_VIEW, Capabilities::TENANT_INVENTORY_SYNC_RUN, Capabilities::TENANT_FINDINGS_VIEW, Capabilities::TENANT_FINDINGS_TRIAGE, diff --git a/apps/platform/app/Support/Audit/AuditActionId.php b/apps/platform/app/Support/Audit/AuditActionId.php index 6906c96d..094366f7 100644 --- a/apps/platform/app/Support/Audit/AuditActionId.php +++ b/apps/platform/app/Support/Audit/AuditActionId.php @@ -99,6 +99,8 @@ enum AuditActionId: string case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed'; case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed'; + case SupportDiagnosticsOpened = 'support_diagnostics.opened'; + // Workspace selection / switch events (Spec 107). case WorkspaceAutoSelected = 'workspace.auto_selected'; case WorkspaceSelected = 'workspace.selected'; @@ -234,6 +236,7 @@ private static function labels(): array self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created', self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed', self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed', + self::SupportDiagnosticsOpened->value => 'Support diagnostics opened', 'baseline.capture.started' => 'Baseline capture started', 'baseline.capture.completed' => 'Baseline capture completed', 'baseline.capture.failed' => 'Baseline capture failed', @@ -315,6 +318,7 @@ private static function summaries(): array self::TenantReviewArchived->value => 'Tenant review archived', self::TenantReviewExported->value => 'Tenant review exported', self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created', + self::SupportDiagnosticsOpened->value => 'Support diagnostics opened', ]; } diff --git a/apps/platform/app/Support/Auth/Capabilities.php b/apps/platform/app/Support/Auth/Capabilities.php index a5f32a7d..0584fc55 100644 --- a/apps/platform/app/Support/Auth/Capabilities.php +++ b/apps/platform/app/Support/Auth/Capabilities.php @@ -69,6 +69,9 @@ class Capabilities public const TENANT_SYNC = 'tenant.sync'; + // Support diagnostics + public const SUPPORT_DIAGNOSTICS_VIEW = 'support_diagnostics.view'; + // Inventory public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run'; diff --git a/apps/platform/app/Support/RedactionIntegrity.php b/apps/platform/app/Support/RedactionIntegrity.php index e2468877..63fef58f 100644 --- a/apps/platform/app/Support/RedactionIntegrity.php +++ b/apps/platform/app/Support/RedactionIntegrity.php @@ -15,6 +15,35 @@ public static function protectedValueNote(): string return 'Protected values are intentionally hidden as [REDACTED]. Secret-only changes remain detectable without revealing the value.'; } + public static function supportDiagnosticsNote(): string + { + return 'Support diagnostics are default-redacted. Secrets, credentials, raw provider payloads, full response bodies, and unrestricted log excerpts are intentionally excluded.'; + } + + /** + * @return list + */ + public static function supportDiagnosticsMarkers(): array + { + return [ + [ + 'path' => 'provider_connection.credential', + 'reason' => 'credential', + 'replacement_text' => '[REDACTED]', + ], + [ + 'path' => 'stored_reports.payload', + 'reason' => 'raw_payload', + 'replacement_text' => '[REDACTED]', + ], + [ + 'path' => 'audit_logs.metadata.raw', + 'reason' => 'restricted_log_excerpt', + 'replacement_text' => '[REDACTED]', + ], + ]; + } + public static function noteForPolicyVersion(PolicyVersion $version): ?string { if (self::fingerprintCount($version->secret_fingerprints) > 0) { diff --git a/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php b/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php new file mode 100644 index 00000000..020a35cd --- /dev/null +++ b/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php @@ -0,0 +1,942 @@ + + */ + public function forTenant(Tenant $tenant, ?User $actor = null): array + { + $tenant->loadMissing('workspace'); + + $workspace = $tenant->workspace; + $providerConnection = $this->tenantProviderConnection($tenant); + $operationRun = $this->tenantOperationRun($tenant); + $findings = $this->tenantFindings($tenant); + $storedReports = $this->tenantStoredReports($tenant); + $tenantReview = $this->tenantReview($tenant); + $reviewPack = $this->tenantReviewPack($tenant, $tenantReview); + $auditLogs = $this->tenantAuditLogs($tenant); + + return $this->bundle( + contextType: 'tenant', + workspace: $workspace, + tenant: $tenant, + operationRun: $operationRun, + headline: 'Support diagnostics for '.$tenant->name, + dominantIssue: $this->tenantDominantIssue($providerConnection, $operationRun, $findings), + sections: [ + $this->overviewSection($workspace, $tenant, $operationRun), + $this->providerConnectionSection($providerConnection, $tenant), + $this->operationContextSection($operationRun, $tenant), + $this->findingsSection($findings, $tenant), + $this->storedReportsSection($storedReports), + $this->tenantReviewSection($tenantReview, $tenant), + $this->reviewPackSection($reviewPack, $tenant), + $this->auditHistorySection($auditLogs), + ], + ); + } + + /** + * @return array + */ + public function forOperationRun(OperationRun $run, ?User $actor = null): array + { + $run->loadMissing(['workspace', 'tenant']); + + $workspace = $run->workspace; + $tenant = $run->tenant; + $providerConnection = $tenant instanceof Tenant + ? $this->operationProviderConnection($run, $tenant) + : null; + $findings = $tenant instanceof Tenant ? $this->operationFindings($run, $tenant) : collect(); + $storedReports = $tenant instanceof Tenant ? $this->tenantStoredReports($tenant) : collect(); + $tenantReview = $tenant instanceof Tenant ? $this->operationTenantReview($run, $tenant) : null; + $reviewPack = $tenant instanceof Tenant ? $this->operationReviewPack($run, $tenant, $tenantReview) : null; + $auditLogs = $this->operationAuditLogs($run); + $runSummary = $this->runSummaryBuilder->build($run); + $runSummaryArray = $runSummary?->toArray(); + + return $this->bundle( + contextType: 'operation_run', + workspace: $workspace, + tenant: $tenant, + operationRun: $run, + headline: $runSummaryArray['headline'] ?? OperationRunLinks::identifier($run).' support diagnostics', + dominantIssue: (string) data_get( + $runSummaryArray, + 'dominantCause.explanation', + $this->operationDominantIssue($run), + ), + sections: [ + $this->overviewSection($workspace, $tenant, $run), + $this->providerConnectionSection($providerConnection, $tenant, $run), + $this->operationContextSection($run, $tenant, $runSummaryArray), + $this->findingsSection($findings, $tenant), + $this->storedReportsSection($storedReports), + $this->tenantReviewSection($tenantReview, $tenant), + $this->reviewPackSection($reviewPack, $tenant), + $this->auditHistorySection($auditLogs), + ], + ); + } + + /** + * @param list> $sections + * @return array + */ + private function bundle( + string $contextType, + ?Workspace $workspace, + ?Tenant $tenant, + ?OperationRun $operationRun, + string $headline, + string $dominantIssue, + array $sections, + ): array { + $sections = $this->sortSections($sections); + $redactionMarkers = RedactionIntegrity::supportDiagnosticsMarkers(); + + return [ + 'context_type' => $contextType, + 'context' => [ + 'type' => $contextType, + 'workspace_id' => $workspace instanceof Workspace ? (int) $workspace->getKey() : null, + 'tenant_id' => $tenant instanceof Tenant ? (int) $tenant->getKey() : null, + 'operation_run_id' => $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null, + 'tenant_label' => $tenant?->name, + 'workspace_label' => $workspace?->name, + ], + 'workspace' => $workspace instanceof Workspace ? [ + 'record_id' => (string) $workspace->getKey(), + 'label' => $workspace->name, + ] : null, + 'tenant' => $tenant instanceof Tenant ? $this->tenantReference($tenant) : null, + 'operation_run' => $operationRun instanceof OperationRun ? $this->operationReference($operationRun, $tenant) : null, + 'headline' => $headline, + 'dominant_issue' => $dominantIssue, + 'freshness_state' => $this->freshnessState($sections), + 'redaction_mode' => 'default_redacted', + 'summary' => [ + 'headline' => $headline, + 'dominant_issue' => $dominantIssue, + 'freshness_state' => $this->freshnessState($sections), + 'completeness_note' => $this->completenessNote($sections), + 'redaction_note' => RedactionIntegrity::supportDiagnosticsNote(), + 'generated_from' => 'derived_existing_truth', + ], + 'sections' => $sections, + 'redaction' => [ + 'mode' => 'default_redacted', + 'markers' => $redactionMarkers, + ], + 'notes' => array_values(array_filter([ + RedactionIntegrity::supportDiagnosticsNote(), + $this->completenessNote($sections), + ])), + ]; + } + + private function tenantProviderConnection(Tenant $tenant): ?ProviderConnection + { + return ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('workspace_id', (int) $tenant->workspace_id) + ->orderByDesc('is_default') + ->orderByDesc('last_health_check_at') + ->orderByDesc('id') + ->first(); + } + + private function operationProviderConnection(OperationRun $run, Tenant $tenant): ?ProviderConnection + { + $context = is_array($run->context) ? $run->context : []; + $providerConnectionId = data_get($context, 'provider_connection_id'); + + if (is_numeric($providerConnectionId)) { + $connection = ProviderConnection::query() + ->whereKey((int) $providerConnectionId) + ->where('workspace_id', (int) $tenant->workspace_id) + ->where('tenant_id', (int) $tenant->getKey()) + ->first(); + + if ($connection instanceof ProviderConnection) { + return $connection; + } + } + + return $this->tenantProviderConnection($tenant); + } + + private function tenantOperationRun(Tenant $tenant): ?OperationRun + { + return OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('workspace_id', (int) $tenant->workspace_id) + ->orderByRaw('completed_at IS NULL') + ->orderByDesc('completed_at') + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + } + + /** + * @return Collection + */ + private function tenantFindings(Tenant $tenant): Collection + { + return Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('workspace_id', (int) $tenant->workspace_id) + ->whereIn('status', Finding::openStatusesForQuery()) + ->orderByRaw("CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END") + ->orderByDesc('last_seen_at') + ->orderBy('id') + ->limit(3) + ->get(); + } + + /** + * @return Collection + */ + private function operationFindings(OperationRun $run, Tenant $tenant): Collection + { + $runBound = Finding::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('workspace_id', (int) $tenant->workspace_id) + ->where(function (Builder $query) use ($run): void { + $query + ->where('current_operation_run_id', (int) $run->getKey()) + ->orWhere('baseline_operation_run_id', (int) $run->getKey()); + }) + ->orderByRaw("CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END") + ->orderByDesc('last_seen_at') + ->orderBy('id') + ->limit(3) + ->get(); + + return $runBound->isNotEmpty() ? $runBound : $this->tenantFindings($tenant); + } + + /** + * @return Collection + */ + private function tenantStoredReports(Tenant $tenant): Collection + { + return StoredReport::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('workspace_id', (int) $tenant->workspace_id) + ->orderByDesc('updated_at') + ->orderByDesc('id') + ->limit(3) + ->get(); + } + + private function tenantReview(Tenant $tenant): ?TenantReview + { + return TenantReview::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('workspace_id', (int) $tenant->workspace_id) + ->orderByDesc('generated_at') + ->orderByDesc('id') + ->first(); + } + + private function operationTenantReview(OperationRun $run, Tenant $tenant): ?TenantReview + { + $review = TenantReview::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('workspace_id', (int) $tenant->workspace_id) + ->where('operation_run_id', (int) $run->getKey()) + ->orderByDesc('generated_at') + ->orderByDesc('id') + ->first(); + + return $review instanceof TenantReview ? $review : $this->tenantReview($tenant); + } + + private function tenantReviewPack(Tenant $tenant, ?TenantReview $tenantReview): ?ReviewPack + { + if ($tenantReview instanceof TenantReview && is_numeric($tenantReview->current_export_review_pack_id)) { + $pack = ReviewPack::query() + ->whereKey((int) $tenantReview->current_export_review_pack_id) + ->where('tenant_id', (int) $tenant->getKey()) + ->where('workspace_id', (int) $tenant->workspace_id) + ->first(); + + if ($pack instanceof ReviewPack) { + return $pack; + } + } + + return ReviewPack::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('workspace_id', (int) $tenant->workspace_id) + ->orderByDesc('generated_at') + ->orderByDesc('id') + ->first(); + } + + private function operationReviewPack(OperationRun $run, Tenant $tenant, ?TenantReview $tenantReview): ?ReviewPack + { + $pack = ReviewPack::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('workspace_id', (int) $tenant->workspace_id) + ->where('operation_run_id', (int) $run->getKey()) + ->orderByDesc('generated_at') + ->orderByDesc('id') + ->first(); + + return $pack instanceof ReviewPack ? $pack : $this->tenantReviewPack($tenant, $tenantReview); + } + + /** + * @return Collection + */ + private function tenantAuditLogs(Tenant $tenant): Collection + { + return AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('workspace_id', (int) $tenant->workspace_id) + ->latestFirst() + ->limit(5) + ->get(); + } + + /** + * @return Collection + */ + private function operationAuditLogs(OperationRun $run): Collection + { + return AuditLog::query() + ->where('workspace_id', (int) $run->workspace_id) + ->where(function (Builder $query) use ($run): void { + $query + ->where('operation_run_id', (int) $run->getKey()) + ->orWhere(function (Builder $targetQuery) use ($run): void { + $targetQuery + ->where('resource_type', 'operation_run') + ->where('resource_id', (string) $run->getKey()); + }); + }) + ->latestFirst() + ->limit(5) + ->get(); + } + + private function tenantDominantIssue(?ProviderConnection $providerConnection, ?OperationRun $operationRun, Collection $findings): string + { + if ($providerConnection instanceof ProviderConnection) { + $providerIssue = $this->providerIssue($providerConnection); + + if ($providerIssue !== null) { + return $providerIssue; + } + } + + if ($operationRun instanceof OperationRun && in_array((string) $operationRun->outcome, ['failed', 'blocked', 'partially_succeeded'], true)) { + return $this->operationDominantIssue($operationRun); + } + + if ($findings->isNotEmpty()) { + return 'Open findings need review before support can treat this tenant as quiet.'; + } + + return 'No dominant support blocker is currently visible from the selected tenant context.'; + } + + private function operationDominantIssue(OperationRun $run): string + { + $failure = collect(is_array($run->failure_summary) ? $run->failure_summary : []) + ->first(static fn (mixed $item): bool => is_array($item) && trim((string) ($item['message'] ?? '')) !== ''); + + if (is_array($failure)) { + return trim((string) $failure['message']); + } + + return match ((string) $run->outcome) { + 'failed' => 'The operation failed and needs follow-up.', + 'blocked' => 'The operation was blocked by a prerequisite or policy condition.', + 'partially_succeeded' => 'The operation completed with degraded or partial results.', + default => 'The operation is available for support review.', + }; + } + + private function providerIssue(ProviderConnection $connection): ?string + { + $reasonCode = trim((string) $connection->last_error_reason_code); + + if ($reasonCode !== '') { + $envelope = $this->providerReasonTranslator->translate($reasonCode, 'support_diagnostics', [ + 'tenant' => $connection->tenant, + 'connection' => $connection, + ]); + + if ($envelope !== null) { + return $envelope->operatorLabel.': '.$envelope->shortExplanation; + } + } + + $surface = ProviderConnectionSurfaceSummary::forConnection($connection); + + if ($surface->readinessSummary !== 'Ready') { + return 'Provider connection '.$surface->readinessSummary.'.'; + } + + return null; + } + + private function overviewSection(?Workspace $workspace, ?Tenant $tenant, ?OperationRun $operationRun): array + { + $references = array_values(array_filter([ + $tenant instanceof Tenant ? $this->tenantReference($tenant) : null, + $operationRun instanceof OperationRun ? $this->operationReference($operationRun, $tenant) : null, + ])); + + return $this->section( + key: 'overview', + label: 'Overview', + availability: $references === [] ? 'missing' : 'available', + summary: $tenant instanceof Tenant + ? 'Workspace and tenant scope resolved before support diagnostics were composed.' + : 'Workspace scope resolved; no tenant context is attached to this operation.', + references: $references, + ); + } + + private function providerConnectionSection(?ProviderConnection $connection, ?Tenant $tenant, ?OperationRun $run = null): array + { + if (! $connection instanceof ProviderConnection) { + return $this->section( + key: 'provider_connection', + label: 'Provider connection', + availability: 'missing', + summary: 'No provider connection was found for this support context.', + references: [ + $this->missingReference('provider_connection', 'Provider connection not observed', 'Open provider connection'), + ], + redactionMarkers: [ + [ + 'path' => 'provider_connection.credential', + 'reason' => 'credential', + 'replacement_text' => '[REDACTED]', + ], + ], + ); + } + + $surface = ProviderConnectionSurfaceSummary::forConnection($connection); + $providerIssue = $this->providerIssue($connection); + + return $this->section( + key: 'provider_connection', + label: 'Provider connection', + availability: $surface->readinessSummary === 'Ready' ? 'available' : 'stale', + summary: $providerIssue ?? sprintf( + '%s provider connection is %s.', + ucfirst($surface->provider), + strtolower($surface->readinessSummary), + ), + freshnessNote: $this->freshnessNote($connection->last_health_check_at, 'Last health check'), + references: [ + $this->modelReference( + type: 'provider_connection', + record: $connection, + label: $connection->display_name ?: 'Provider connection #'.$connection->getKey(), + actionLabel: 'Open provider connection', + url: ProviderConnectionResource::getUrl('view', ['record' => $connection], panel: 'admin'), + freshnessAt: $connection->last_health_check_at, + ), + ], + redactionMarkers: [ + [ + 'path' => 'provider_connection.credential', + 'reason' => 'credential', + 'replacement_text' => '[REDACTED]', + ], + ], + ); + } + + private function operationContextSection(?OperationRun $operationRun, ?Tenant $tenant, ?array $runSummary = null): array + { + if (! $operationRun instanceof OperationRun) { + return $this->section( + key: 'operation_context', + label: 'Operation context', + availability: 'missing', + summary: 'No recent operation context was found for this support context.', + references: [ + $this->missingReference('operation_run', 'Operation not yet observed', OperationRunLinks::openLabel()), + ], + ); + } + + $runSummary ??= $this->runSummaryBuilder->build($operationRun)?->toArray(); + + return $this->section( + key: 'operation_context', + label: 'Operation context', + availability: 'available', + summary: (string) ($runSummary['headline'] ?? $this->operationDominantIssue($operationRun)), + freshnessNote: $this->freshnessNote($operationRun->completed_at ?? $operationRun->updated_at, 'Last run update'), + references: [ + $this->operationReference($operationRun, $tenant), + ...$this->operationRelatedReferences($operationRun, $tenant), + ], + ); + } + + private function findingsSection(Collection $findings, ?Tenant $tenant): array + { + if ($findings->isEmpty()) { + return $this->section( + key: 'findings', + label: 'Findings', + availability: 'missing', + summary: 'No open or run-related findings were found for this support context.', + references: [ + $this->missingReference('finding', 'No relevant finding observed', 'Open finding'), + ], + ); + } + + return $this->section( + key: 'findings', + label: 'Findings', + availability: 'available', + summary: sprintf('%d finding reference(s) are included for support triage.', $findings->count()), + freshnessNote: $this->freshnessNote($findings->max('last_seen_at'), 'Latest finding'), + references: $findings + ->map(fn (Finding $finding): array => $this->modelReference( + type: 'finding', + record: $finding, + label: sprintf('%s finding #%d', ucfirst(str_replace('_', ' ', (string) $finding->severity)), (int) $finding->getKey()), + actionLabel: 'Open finding', + url: $tenant instanceof Tenant + ? FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant) + : null, + freshnessAt: $finding->last_seen_at, + )) + ->values() + ->all(), + ); + } + + private function storedReportsSection(Collection $reports): array + { + if ($reports->isEmpty()) { + return $this->section( + key: 'stored_reports', + label: 'Stored reports', + availability: 'missing', + summary: 'No stored report identity was found for this support context.', + references: [ + $this->missingReference('stored_report', 'Stored report not yet observed', 'Review stored report identity'), + ], + redactionMarkers: [ + [ + 'path' => 'stored_reports.payload', + 'reason' => 'raw_payload', + 'replacement_text' => '[REDACTED]', + ], + ], + ); + } + + return $this->section( + key: 'stored_reports', + label: 'Stored reports', + availability: 'redacted', + summary: sprintf('%d stored report identity reference(s) are included without raw report payloads.', $reports->count()), + freshnessNote: $this->freshnessNote($reports->max('updated_at'), 'Latest stored report'), + references: $reports + ->map(fn (StoredReport $report): array => $this->modelReference( + type: 'stored_report', + record: $report, + label: str_replace('_', ' ', (string) $report->report_type).' report #'.$report->getKey(), + actionLabel: 'Review stored report identity', + url: null, + freshnessAt: $report->updated_at, + )) + ->values() + ->all(), + redactionMarkers: [ + [ + 'path' => 'stored_reports.payload', + 'reason' => 'raw_payload', + 'replacement_text' => '[REDACTED]', + ], + ], + ); + } + + private function tenantReviewSection(?TenantReview $review, ?Tenant $tenant): array + { + if (! $review instanceof TenantReview) { + return $this->section( + key: 'tenant_review', + label: 'Tenant review', + availability: 'missing', + summary: 'No tenant review was found for this support context.', + references: [ + $this->missingReference('tenant_review', 'Tenant review not yet observed', 'Open tenant review'), + ], + ); + } + + return $this->section( + key: 'tenant_review', + label: 'Tenant review', + availability: 'available', + summary: sprintf('Latest tenant review is %s with %s completeness.', (string) $review->status, (string) $review->completeness_state), + freshnessNote: $this->freshnessNote($review->generated_at, 'Generated'), + references: [ + $this->modelReference( + type: 'tenant_review', + record: $review, + label: 'Tenant review #'.$review->getKey(), + actionLabel: 'Open tenant review', + url: $tenant instanceof Tenant + ? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant) + : null, + freshnessAt: $review->generated_at, + ), + ], + ); + } + + private function reviewPackSection(?ReviewPack $pack, ?Tenant $tenant): array + { + if (! $pack instanceof ReviewPack) { + return $this->section( + key: 'review_pack', + label: 'Review pack', + availability: 'missing', + summary: 'No review pack was found for this support context.', + references: [ + $this->missingReference('review_pack', 'Review pack not yet observed', 'Open review pack'), + ], + ); + } + + return $this->section( + key: 'review_pack', + label: 'Review pack', + availability: $pack->isExpired() ? 'stale' : 'available', + summary: sprintf('Review pack #%d is %s.', (int) $pack->getKey(), (string) $pack->status), + freshnessNote: $this->freshnessNote($pack->generated_at, 'Generated'), + references: [ + $this->modelReference( + type: 'review_pack', + record: $pack, + label: 'Review pack #'.$pack->getKey(), + actionLabel: 'Open review pack', + url: $tenant instanceof Tenant + ? ReviewPackResource::getUrl('view', ['record' => $pack], panel: 'tenant', tenant: $tenant) + : null, + freshnessAt: $pack->generated_at, + ), + ], + ); + } + + private function auditHistorySection(Collection $auditLogs): array + { + if ($auditLogs->isEmpty()) { + return $this->section( + key: 'audit_history', + label: 'Audit history', + availability: 'missing', + summary: 'No audit references were found for this support context.', + references: [ + $this->missingReference('audit_log', 'Audit event not yet observed', 'Inspect event'), + ], + redactionMarkers: [ + [ + 'path' => 'audit_logs.metadata.raw', + 'reason' => 'restricted_log_excerpt', + 'replacement_text' => '[REDACTED]', + ], + ], + ); + } + + return $this->section( + key: 'audit_history', + label: 'Audit history', + availability: 'redacted', + summary: sprintf('%d audit reference(s) are included with redacted metadata only.', $auditLogs->count()), + freshnessNote: $this->freshnessNote($auditLogs->max('recorded_at'), 'Latest audit event'), + references: $auditLogs + ->map(fn (AuditLog $auditLog): array => $this->modelReference( + type: 'audit_log', + record: $auditLog, + label: $auditLog->summaryText(), + actionLabel: 'Inspect event', + url: route('admin.monitoring.audit-log', ['event' => (int) $auditLog->getKey()]), + freshnessAt: $auditLog->recorded_at, + )) + ->values() + ->all(), + redactionMarkers: [ + [ + 'path' => 'audit_logs.metadata.raw', + 'reason' => 'restricted_log_excerpt', + 'replacement_text' => '[REDACTED]', + ], + ], + ); + } + + /** + * @param list> $references + * @param list $redactionMarkers + * @return array + */ + private function section( + string $key, + string $label, + string $availability, + string $summary, + ?string $freshnessNote = null, + array $references = [], + array $redactionMarkers = [], + ): array { + return [ + 'key' => $key, + 'label' => $label, + 'availability' => $availability, + 'summary' => $summary, + 'freshness_note' => $freshnessNote, + 'references' => $this->sortReferences($references), + 'redaction_markers' => $redactionMarkers, + ]; + } + + /** + * @param list> $sections + * @return list> + */ + private function sortSections(array $sections): array + { + $order = array_flip(self::SECTION_ORDER); + + usort($sections, static fn (array $left, array $right): int => ($order[$left['key']] ?? 999) <=> ($order[$right['key']] ?? 999)); + + return array_values($sections); + } + + /** + * @param list> $references + * @return list> + */ + private function sortReferences(array $references): array + { + usort($references, function (array $left, array $right): int { + $leftAvailability = $left['availability'] === 'available' ? 0 : 1; + $rightAvailability = $right['availability'] === 'available' ? 0 : 1; + + if ($leftAvailability !== $rightAvailability) { + return $leftAvailability <=> $rightAvailability; + } + + return [ + (string) ($left['type'] ?? ''), + (string) ($left['record_id'] ?? ''), + (string) ($left['label'] ?? ''), + ] <=> [ + (string) ($right['type'] ?? ''), + (string) ($right['record_id'] ?? ''), + (string) ($right['label'] ?? ''), + ]; + }); + + return array_values($references); + } + + private function tenantReference(Tenant $tenant): array + { + return [ + 'type' => 'tenant', + 'record_id' => (string) $tenant->getKey(), + 'label' => $tenant->name, + 'action_label' => 'Open tenant', + 'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant), + 'availability' => 'available', + 'freshness_note' => null, + 'access_reason' => null, + ]; + } + + private function operationReference(OperationRun $run, ?Tenant $tenant): array + { + return $this->modelReference( + type: 'operation_run', + record: $run, + label: OperationRunLinks::identifier($run), + actionLabel: OperationRunLinks::openLabel(), + url: OperationRunLinks::tenantlessView($run), + freshnessAt: $run->completed_at ?? $run->updated_at, + ); + } + + /** + * @return list> + */ + private function operationRelatedReferences(OperationRun $run, ?Tenant $tenant): array + { + if (! $tenant instanceof Tenant) { + return []; + } + + return collect($this->relatedNavigationResolver->operationLinks($run, $tenant)) + ->reject(static fn (string $url, string $label): bool => $label === OperationRunLinks::collectionLabel()) + ->map(fn (string $url, string $label): array => [ + 'type' => 'operation_run', + 'record_id' => (string) $run->getKey(), + 'label' => $label, + 'action_label' => $label, + 'url' => $url, + 'availability' => 'available', + 'freshness_note' => null, + 'access_reason' => null, + ]) + ->values() + ->all(); + } + + private function modelReference( + string $type, + Model $record, + string $label, + string $actionLabel, + ?string $url, + mixed $freshnessAt = null, + ): array { + return [ + 'type' => $type, + 'record_id' => (string) $record->getKey(), + 'label' => $label, + 'action_label' => $actionLabel, + 'url' => $url, + 'availability' => $url === null && $type !== 'stored_report' ? 'inaccessible' : 'available', + 'freshness_note' => $this->freshnessNote($freshnessAt), + 'access_reason' => $url === null && $type !== 'stored_report' ? 'Canonical destination is not available from this context.' : null, + ]; + } + + private function missingReference(string $type, string $label, string $actionLabel): array + { + return [ + 'type' => $type, + 'record_id' => null, + 'label' => $label, + 'action_label' => $actionLabel, + 'url' => null, + 'availability' => 'missing', + 'freshness_note' => null, + 'access_reason' => 'No authorized record is available for this support context.', + ]; + } + + private function freshnessNote(mixed $value, string $prefix = 'Observed'): ?string + { + if ($value instanceof CarbonInterface) { + return $prefix.': '.$value->toIso8601String(); + } + + if (is_string($value) && trim($value) !== '') { + return $prefix.': '.trim($value); + } + + return null; + } + + /** + * @param list> $sections + */ + private function freshnessState(array $sections): string + { + $availableCount = count(array_filter($sections, static fn (array $section): bool => $section['availability'] === 'available')); + $missingCount = count(array_filter($sections, static fn (array $section): bool => $section['availability'] === 'missing')); + $staleCount = count(array_filter($sections, static fn (array $section): bool => $section['availability'] === 'stale')); + $redactedCount = count(array_filter($sections, static fn (array $section): bool => $section['availability'] === 'redacted')); + + if ($availableCount === 0 && $redactedCount === 0) { + return 'missing_context'; + } + + if ($missingCount > 0 || $staleCount > 0) { + return 'mixed'; + } + + return 'fresh'; + } + + /** + * @param list> $sections + */ + private function completenessNote(array $sections): ?string + { + $missing = collect($sections) + ->filter(static fn (array $section): bool => $section['availability'] === 'missing') + ->pluck('label') + ->values() + ->all(); + + if ($missing === []) { + return null; + } + + return 'Missing context: '.implode(', ', $missing).'.'; + } +} diff --git a/apps/platform/resources/views/filament/modals/support-diagnostic-bundle.blade.php b/apps/platform/resources/views/filament/modals/support-diagnostic-bundle.blade.php new file mode 100644 index 00000000..b6ec3b72 --- /dev/null +++ b/apps/platform/resources/views/filament/modals/support-diagnostic-bundle.blade.php @@ -0,0 +1,162 @@ +@php + use Illuminate\Support\Str; + + /** @var array $bundle */ + $summary = is_array($bundle['summary'] ?? null) ? $bundle['summary'] : []; + $context = is_array($bundle['context'] ?? null) ? $bundle['context'] : []; + $sections = is_array($bundle['sections'] ?? null) ? $bundle['sections'] : []; + $redaction = is_array($bundle['redaction'] ?? null) ? $bundle['redaction'] : []; + $notes = is_array($bundle['notes'] ?? null) ? $bundle['notes'] : []; + + $availabilityColor = static function (?string $availability): string { + return match ($availability) { + 'available', 'current', 'fresh', 'ready' => 'success', + 'partial', 'stale' => 'warning', + 'error', 'missing', 'unavailable' => 'danger', + default => 'gray', + }; + }; + + $referenceDescription = static function (array $reference): string { + $parts = [ + is_string($reference['type'] ?? null) && trim((string) $reference['type']) !== '' + ? (string) $reference['type'] + : 'reference', + is_string($reference['availability'] ?? null) && trim((string) $reference['availability']) !== '' + ? (string) $reference['availability'] + : 'missing', + ]; + + if (is_string($reference['freshness_note'] ?? null) && trim((string) $reference['freshness_note']) !== '') { + $parts[] = (string) $reference['freshness_note']; + } + + return implode(' - ', $parts); + }; +@endphp + +
+ + +
+ + {{ data_get($context, 'type', data_get($bundle, 'context_type', 'tenant')) }} + + + {{ data_get($summary, 'freshness_state', data_get($bundle, 'freshness_state', 'mixed')) }} + + + {{ str_replace('_', '-', (string) data_get($redaction, 'mode', data_get($bundle, 'redaction_mode', 'default-redacted'))) }} + +
+
+ +
+
+
Workspace
+
{{ data_get($context, 'workspace_label', 'Workspace unavailable') }}
+
+
+
Tenant
+
{{ data_get($context, 'tenant_label', 'Tenant unavailable') }}
+
+
+
+ + @if ($notes !== []) + +
+ @foreach ($notes as $note) + @if (is_string($note) && trim($note) !== '') +
+ Note +

{{ $note }}

+
+ @endif + @endforeach +
+
+ @endif + +
+ @foreach ($sections as $section) + @php + $references = is_array($section['references'] ?? null) ? $section['references'] : []; + $markers = is_array($section['redaction_markers'] ?? null) ? $section['redaction_markers'] : []; + $availability = is_string($section['availability'] ?? null) && trim((string) $section['availability']) !== '' + ? (string) $section['availability'] + : 'missing'; + $sectionLabel = $section['label'] ?? $section['key'] ?? 'Section'; + $sectionSummary = $section['summary'] ?? 'No summary available.'; + @endphp + + + + + {{ $availability }} + + + +
+ @if (is_string($section['freshness_note'] ?? null) && trim((string) $section['freshness_note']) !== '') +

{{ $section['freshness_note'] }}

+ @endif + + @if ($references !== []) +
+ @foreach ($references as $reference) + @php + $referenceLabel = $reference['label'] ?? 'Reference unavailable'; + $referenceUrl = is_string($reference['url'] ?? null) && trim((string) $reference['url']) !== '' + ? (string) $reference['url'] + : null; + $referenceActionLabel = $reference['action_label'] ?? ($referenceUrl ? 'Open' : 'Unavailable'); + @endphp + + + + @if ($referenceUrl) + + {{ $referenceActionLabel }} + + @else + + {{ $referenceActionLabel }} + + @endif + + + @endforeach +
+ @endif + + @if ($markers !== []) +
+ @foreach ($markers as $marker) + + {{ trim((string) (($marker['replacement_text'] ?? '[REDACTED]').' '.Str::of((string) ($marker['reason'] ?? 'redacted'))->replace('_', ' '))) }} + + @endforeach +
+ @endif +
+
+ @endforeach +
+
diff --git a/apps/platform/tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php b/apps/platform/tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php new file mode 100644 index 00000000..b727a3f1 --- /dev/null +++ b/apps/platform/tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php @@ -0,0 +1,192 @@ +actingAs($user); + Filament::setTenant(null, true); + session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id); + + return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]); +} + +it('opens a redacted support diagnostic bundle from the tenantless operation viewer', function (): void { + $tenant = Tenant::factory()->create(['name' => 'Contoso Support Tenant']); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + $connection = ProviderConnection::factory() + ->withCredential() + ->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'display_name' => 'Contoso Microsoft connection', + 'verification_status' => ProviderVerificationStatus::Blocked->value, + 'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing, + 'last_error_message' => 'raw-provider-secret-message', + 'last_health_check_at' => now()->subMinutes(15), + ]); + + $run = OperationRun::factory() + ->forTenant($tenant) + ->create([ + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'summary_counts' => [ + 'total' => 0, + 'processed' => 0, + ], + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + 'raw_response_body' => 'secret-provider-body', + ], + 'failure_summary' => [[ + 'message' => 'Run failed after provider validation.', + ]], + 'completed_at' => now()->subMinutes(10), + ]); + + $finding = Finding::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'current_operation_run_id' => (int) $run->getKey(), + 'severity' => Finding::SEVERITY_HIGH, + 'last_seen_at' => now()->subMinutes(8), + ]); + + StoredReport::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE, + 'payload' => [ + 'raw_response_body' => 'stored-report-secret-body', + ], + 'fingerprint' => 'permission-fingerprint', + ]); + + $evidenceSnapshot = EvidenceSnapshot::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'operation_run_id' => (int) $run->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'fingerprint' => fake()->sha256(), + 'status' => 'active', + 'completeness_state' => 'complete', + 'summary' => [ + 'dimension_count' => 1, + 'missing_dimensions' => 0, + 'stale_dimensions' => 0, + ], + 'generated_at' => now()->subMinutes(7), + ]); + + $review = TenantReview::factory()->ready()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'evidence_snapshot_id' => (int) $evidenceSnapshot->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'generated_at' => now()->subMinutes(7), + ]); + + $pack = ReviewPack::factory()->ready()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_review_id' => (int) $review->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'generated_at' => now()->subMinutes(6), + ]); + + $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); + + AuditLog::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'action' => 'operation.failed', + 'resource_type' => 'operation_run', + 'resource_id' => (string) $run->getKey(), + 'target_label' => 'Operation #'.$run->getKey(), + 'metadata' => [ + 'raw_response_body' => 'audit-secret-body', + 'reason_code' => 'provider_permission_missing', + ], + 'outcome' => 'success', + 'recorded_at' => now()->subMinutes(5), + ]); + + operationSupportDiagnosticsComponent($user, $run) + ->assertActionVisible('openSupportDiagnostics') + ->assertActionEnabled('openSupportDiagnostics') + ->assertActionExists('openSupportDiagnostics', fn (Action $action): bool => $action->getLabel() === 'Open support diagnostics') + ->assertActionHasIcon('openSupportDiagnostics', 'heroicon-o-lifebuoy') + ->mountAction('openSupportDiagnostics') + ->assertMountedActionModalSee('Support diagnostics') + ->assertMountedActionModalSee(OperationRunLinks::identifier($run)) + ->assertMountedActionModalSee('The compare finished, but no decision-grade result is available yet.') + ->assertMountedActionModalSee('Contoso Microsoft connection') + ->assertMountedActionModalSee('High finding #'.$finding->getKey()) + ->assertMountedActionModalSee('permission posture report') + ->assertMountedActionModalSee('Tenant review #'.$review->getKey()) + ->assertMountedActionModalSee('Review pack #'.$pack->getKey()) + ->assertMountedActionModalSee('Operation failed') + ->assertMountedActionModalSee('default-redacted') + ->assertMountedActionModalSee('[REDACTED]') + ->assertMountedActionModalDontSee('raw-provider-secret-message') + ->assertMountedActionModalDontSee('secret-provider-body') + ->assertMountedActionModalDontSee('stored-report-secret-body') + ->assertMountedActionModalDontSee('audit-secret-body'); +}); + +it('keeps tenantless operation detail deny-as-not-found for workspace members without tenant entitlement', function (): void { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $workspace->getKey(), + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->assertNotFound(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php b/apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php new file mode 100644 index 00000000..e55009e2 --- /dev/null +++ b/apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php @@ -0,0 +1,186 @@ +actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + setTenantPanelContext($tenant); + + return Livewire::actingAs($user)->test(TenantDashboard::class); +} + +function supportDiagnosticsOperationAuditComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable +{ + test()->actingAs($user); + Filament::setTenant(null, true); + session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id); + + return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]); +} + +function seedSupportDiagnosticsAuditFixture(string $role = 'owner'): array +{ + $tenant = Tenant::factory()->create(['name' => 'Audit Tenant']); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: $role); + + $connection = ProviderConnection::factory() + ->withCredential() + ->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'display_name' => 'Audit connection', + 'verification_status' => ProviderVerificationStatus::Blocked->value, + 'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing, + 'last_error_message' => 'raw-provider-secret-message', + 'last_health_check_at' => now()->subMinutes(15), + ]); + + $run = OperationRun::factory() + ->forTenant($tenant) + ->create([ + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'summary_counts' => [ + 'total' => 0, + 'processed' => 0, + ], + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + 'raw_response_body' => 'secret-provider-body', + ], + 'completed_at' => now()->subMinutes(10), + ]); + + StoredReport::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE, + 'payload' => [ + 'raw_response_body' => 'stored-report-secret-body', + ], + ]); + + AuditLog::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'action' => 'operation.failed', + 'resource_type' => 'operation_run', + 'resource_id' => (string) $run->getKey(), + 'target_label' => 'Operation #'.$run->getKey(), + 'metadata' => [ + 'raw_response_body' => 'audit-secret-body', + ], + 'outcome' => 'success', + 'recorded_at' => now()->subMinutes(5), + ]); + + return [$user, $tenant, $run]; +} + +it('audits tenant support diagnostics opens with redacted metadata and no side effects', function (): void { + [$user, $tenant] = seedSupportDiagnosticsAuditFixture(); + + bindFailHardGraphClient(); + Bus::fake(); + Queue::fake(); + + $operationRunCount = OperationRun::query()->count(); + + assertNoOutboundHttp(function () use ($user, $tenant): void { + supportDiagnosticsTenantAuditComponent($user, $tenant) + ->mountAction('openSupportDiagnostics') + ->assertMountedActionModalSee('Support diagnostics'); + }); + + Bus::assertNothingDispatched(); + Queue::assertNothingPushed(); + + $audit = AuditLog::query() + ->where('action', AuditActionId::SupportDiagnosticsOpened->value) + ->latest('id') + ->firstOrFail(); + + $metadataJson = json_encode($audit->metadata, JSON_THROW_ON_ERROR); + + expect(OperationRun::query()->count())->toBe($operationRunCount) + ->and(AuditLog::query()->where('action', AuditActionId::SupportDiagnosticsOpened->value)->count())->toBe(1) + ->and(AuditLog::query()->where('action', AuditActionId::BaselineCompareStarted->value)->count())->toBe(0) + ->and($audit->resource_type)->toBe('support_diagnostic_bundle') + ->and($audit->resource_id)->toBe('tenant:'.$tenant->getKey()) + ->and($audit->operation_run_id)->toBeNull() + ->and($audit->metadata['context_type'] ?? null)->toBe('tenant') + ->and($audit->metadata['redaction_mode'] ?? null)->toBe('default_redacted') + ->and($audit->metadata['section_count'] ?? null)->toBe(8) + ->and($audit->metadata['reference_count'] ?? null)->toBeGreaterThan(0) + ->and($audit->metadata['primary_context_id'] ?? null)->toBe((string) $tenant->getKey()) + ->and($metadataJson)->not->toContain('raw-provider-secret-message') + ->and($metadataJson)->not->toContain('secret-provider-body') + ->and($metadataJson)->not->toContain('stored-report-secret-body') + ->and($metadataJson)->not->toContain('audit-secret-body'); +}); + +it('audits operation support diagnostics opens with redacted metadata and no side effects', function (): void { + [$user, $tenant, $run] = seedSupportDiagnosticsAuditFixture(); + + bindFailHardGraphClient(); + Bus::fake(); + Queue::fake(); + + $operationRunCount = OperationRun::query()->count(); + + assertNoOutboundHttp(function () use ($user, $run): void { + supportDiagnosticsOperationAuditComponent($user, $run) + ->mountAction('openSupportDiagnostics') + ->assertMountedActionModalSee('Support diagnostics'); + }); + + Bus::assertNothingDispatched(); + Queue::assertNothingPushed(); + + $audit = AuditLog::query() + ->where('action', AuditActionId::SupportDiagnosticsOpened->value) + ->latest('id') + ->firstOrFail(); + + $metadataJson = json_encode($audit->metadata, JSON_THROW_ON_ERROR); + + expect(OperationRun::query()->count())->toBe($operationRunCount) + ->and(AuditLog::query()->where('action', AuditActionId::SupportDiagnosticsOpened->value)->count())->toBe(1) + ->and(AuditLog::query()->where('action', AuditActionId::BaselineCompareStarted->value)->count())->toBe(0) + ->and($audit->resource_type)->toBe('support_diagnostic_bundle') + ->and($audit->resource_id)->toBe('operation_run:'.$run->getKey()) + ->and($audit->operation_run_id)->toBe((int) $run->getKey()) + ->and($audit->metadata['context_type'] ?? null)->toBe('operation_run') + ->and($audit->metadata['redaction_mode'] ?? null)->toBe('default_redacted') + ->and($audit->metadata['section_count'] ?? null)->toBe(8) + ->and($audit->metadata['reference_count'] ?? null)->toBeGreaterThan(0) + ->and($audit->metadata['primary_context_id'] ?? null)->toBe((string) $run->getKey()) + ->and($metadataJson)->not->toContain('raw-provider-secret-message') + ->and($metadataJson)->not->toContain('secret-provider-body') + ->and($metadataJson)->not->toContain('stored-report-secret-body') + ->and($metadataJson)->not->toContain('audit-secret-body'); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php b/apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php new file mode 100644 index 00000000..649abfcf --- /dev/null +++ b/apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php @@ -0,0 +1,115 @@ +actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + setTenantPanelContext($tenant); + + return Livewire::actingAs($user)->test(TenantDashboard::class); +} + +function supportDiagnosticsOperationAuthorizationComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable +{ + test()->actingAs($user); + Filament::setTenant(null, true); + session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id); + + return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]); +} + +it('keeps tenant support diagnostics deny-as-not-found for workspace members without tenant entitlement', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'role' => 'operator', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)) + ->assertNotFound(); +}); + +it('returns forbidden for entitled tenant members without support diagnostics capability', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + + supportDiagnosticsTenantAuthorizationComponent($user, $tenant) + ->assertActionVisible('openSupportDiagnostics') + ->assertActionDisabled('openSupportDiagnostics') + ->call('tenantSupportDiagnosticBundle') + ->assertForbidden(); +}); + +it('keeps operation-run support diagnostics deny-as-not-found for workspace members without tenant entitlement', function (): void { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $workspace->getKey(), + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->assertNotFound(); +}); + +it('returns forbidden for entitled run viewers without support diagnostics capability', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'summary_counts' => [ + 'total' => 0, + 'processed' => 0, + ], + 'completed_at' => now(), + ]); + + supportDiagnosticsOperationAuthorizationComponent($user, $run) + ->assertActionVisible('openSupportDiagnostics') + ->assertActionDisabled('openSupportDiagnostics') + ->call('operationRunSupportDiagnosticBundle') + ->assertForbidden(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php b/apps/platform/tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php new file mode 100644 index 00000000..f437c963 --- /dev/null +++ b/apps/platform/tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php @@ -0,0 +1,186 @@ +actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + setTenantPanelContext($tenant); + + return Livewire::actingAs($user)->test(TenantDashboard::class); +} + +it('opens a redacted tenant support diagnostic bundle from the tenant dashboard', function (): void { + $tenant = Tenant::factory()->create(['name' => 'Contoso Support Tenant']); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + $connection = ProviderConnection::factory() + ->withCredential() + ->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'display_name' => 'Contoso Microsoft connection', + 'verification_status' => ProviderVerificationStatus::Blocked->value, + 'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing, + 'last_error_message' => 'raw-provider-secret-message', + 'last_health_check_at' => now()->subMinutes(15), + ]); + + $run = OperationRun::factory() + ->forTenant($tenant) + ->create([ + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + 'raw_response_body' => 'secret-provider-body', + ], + 'failure_summary' => [[ + 'message' => 'Compare failed after provider permission validation.', + ]], + 'completed_at' => now()->subMinutes(10), + ]); + + $finding = Finding::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'current_operation_run_id' => (int) $run->getKey(), + 'severity' => Finding::SEVERITY_HIGH, + 'last_seen_at' => now()->subMinutes(8), + ]); + + StoredReport::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE, + 'payload' => [ + 'raw_response_body' => 'stored-report-secret-body', + ], + 'fingerprint' => 'permission-fingerprint', + ]); + + $evidenceSnapshot = EvidenceSnapshot::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'operation_run_id' => (int) $run->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'fingerprint' => fake()->sha256(), + 'status' => 'active', + 'completeness_state' => 'complete', + 'summary' => [ + 'dimension_count' => 1, + 'missing_dimensions' => 0, + 'stale_dimensions' => 0, + ], + 'generated_at' => now()->subMinutes(7), + ]); + + $review = TenantReview::factory()->ready()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'evidence_snapshot_id' => (int) $evidenceSnapshot->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'status' => TenantReviewStatus::Ready->value, + 'generated_at' => now()->subMinutes(7), + ]); + + $pack = ReviewPack::factory()->ready()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_review_id' => (int) $review->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'generated_at' => now()->subMinutes(6), + ]); + + $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); + + AuditLog::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'action' => 'operation.failed', + 'resource_type' => 'operation_run', + 'resource_id' => (string) $run->getKey(), + 'target_label' => 'Operation #'.$run->getKey(), + 'metadata' => [ + 'raw_response_body' => 'audit-secret-body', + 'reason_code' => 'provider_permission_missing', + ], + 'outcome' => 'success', + 'recorded_at' => now()->subMinutes(5), + ]); + + tenantSupportDiagnosticsComponent($user, $tenant) + ->assertActionVisible('openSupportDiagnostics') + ->assertActionEnabled('openSupportDiagnostics') + ->assertActionExists('openSupportDiagnostics', fn (Action $action): bool => $action->getLabel() === 'Open support diagnostics') + ->mountAction('openSupportDiagnostics') + ->assertMountedActionModalSee('Support diagnostics') + ->assertMountedActionModalSee('Contoso Support Tenant') + ->assertMountedActionModalSee('Permissions missing') + ->assertMountedActionModalSee('provider app is missing required Microsoft Graph permissions') + ->assertMountedActionModalSee('Operation #'.$run->getKey()) + ->assertMountedActionModalSee('High finding #'.$finding->getKey()) + ->assertMountedActionModalSee('permission posture report') + ->assertMountedActionModalSee('Tenant review #'.$review->getKey()) + ->assertMountedActionModalSee('Review pack #'.$pack->getKey()) + ->assertMountedActionModalSee('Operation failed') + ->assertMountedActionModalSee('default-redacted') + ->assertMountedActionModalSee('[REDACTED]') + ->assertMountedActionModalDontSee('raw-provider-secret-message') + ->assertMountedActionModalDontSee('secret-provider-body') + ->assertMountedActionModalDontSee('stored-report-secret-body') + ->assertMountedActionModalDontSee('audit-secret-body'); +}); + +it('denies non-entitled tenant dashboard access as not found', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'role' => 'operator', + ]); + + $this + ->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)) + ->assertNotFound(); +}); + +it('shows support diagnostics as disabled for entitled members without the support capability', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + + tenantSupportDiagnosticsComponent($user, $tenant) + ->assertActionVisible('openSupportDiagnostics') + ->assertActionDisabled('openSupportDiagnostics') + ->assertActionExists('openSupportDiagnostics', fn (Action $action): bool => $action->getTooltip() === UiTooltips::insufficientPermission()); +}); diff --git a/apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php b/apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php new file mode 100644 index 00000000..91d5c0dc --- /dev/null +++ b/apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php @@ -0,0 +1,128 @@ +create(['name' => 'Deterministic Tenant']); + + ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'display_name' => 'Deterministic connection', + ]); + + OperationRun::factory() + ->forTenant($tenant) + ->create([ + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now()->subMinute(), + ]); + + $firstFinding = Finding::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'severity' => Finding::SEVERITY_LOW, + 'last_seen_at' => now()->subMinutes(4), + ]); + + $secondFinding = Finding::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'severity' => Finding::SEVERITY_CRITICAL, + 'last_seen_at' => now()->subMinutes(2), + ]); + + $builder = app(SupportDiagnosticBundleBuilder::class); + + $firstBundle = $builder->forTenant($tenant); + $secondBundle = $builder->forTenant($tenant->fresh()); + + expect(array_column($firstBundle['sections'], 'key')) + ->toBe([ + 'overview', + 'provider_connection', + 'operation_context', + 'findings', + 'stored_reports', + 'tenant_review', + 'review_pack', + 'audit_history', + ]) + ->and(array_column($firstBundle['sections'], 'key'))->toBe(array_column($secondBundle['sections'], 'key')); + + $firstFindingsSection = collect($firstBundle['sections'])->firstWhere('key', 'findings'); + $secondFindingsSection = collect($secondBundle['sections'])->firstWhere('key', 'findings'); + + expect(array_column($firstFindingsSection['references'], 'record_id')) + ->toBe([(string) $firstFinding->getKey(), (string) $secondFinding->getKey()]) + ->and($firstFindingsSection['references'])->toBe($secondFindingsSection['references']); +}); + +it('degrades missing operation-run support context without failing bundle generation', function (): void { + $workspace = Workspace::factory()->create(); + + $tenantlessRun = OperationRun::factory()->create([ + 'tenant_id' => null, + 'workspace_id' => (int) $workspace->getKey(), + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'summary_counts' => [ + 'total' => 0, + 'processed' => 0, + ], + 'completed_at' => now(), + ]); + + $bundle = app(SupportDiagnosticBundleBuilder::class)->forOperationRun($tenantlessRun); + $sections = collect($bundle['sections'])->keyBy('key'); + + expect($bundle['context']['tenant_id'])->toBeNull() + ->and($bundle['summary']['completeness_note'])->toContain('Provider connection') + ->and($bundle['summary']['completeness_note'])->toContain('Review pack') + ->and($sections['operation_context']['availability'])->toBe('available') + ->and($sections['provider_connection']['availability'])->toBe('missing') + ->and($sections['findings']['availability'])->toBe('missing') + ->and($sections['stored_reports']['availability'])->toBe('missing') + ->and($sections['tenant_review']['availability'])->toBe('missing') + ->and($sections['review_pack']['availability'])->toBe('missing'); +}); + +it('marks references without canonical destinations as inaccessible', function (): void { + $tenant = Tenant::factory()->create(); + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + $method = new \ReflectionMethod(SupportDiagnosticBundleBuilder::class, 'modelReference'); + $reference = $method->invoke( + app(SupportDiagnosticBundleBuilder::class), + 'provider_connection', + $connection, + 'Detached provider connection', + 'Open provider connection', + null, + null, + ); + + expect($reference) + ->toBeArray() + ->and($reference['availability'])->toBe('inaccessible') + ->and($reference['access_reason'])->toBe('Canonical destination is not available from this context.'); +}); \ No newline at end of file diff --git a/apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php b/apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php new file mode 100644 index 00000000..225e0517 --- /dev/null +++ b/apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php @@ -0,0 +1,111 @@ +create(['name' => 'Redaction Tenant']); + + ProviderConnection::factory() + ->withCredential() + ->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'display_name' => 'Redaction connection', + 'verification_status' => ProviderVerificationStatus::Blocked->value, + 'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing, + 'last_error_message' => 'provider-secret-message', + 'last_health_check_at' => now()->subMinutes(15), + ]); + + $bundle = app(SupportDiagnosticBundleBuilder::class)->forTenant($tenant); + $providerSection = collect($bundle['sections'])->firstWhere('key', 'provider_connection'); + + expect($bundle['redaction_mode'])->toBe('default_redacted') + ->and($bundle['summary']['redaction_note'])->toBe(RedactionIntegrity::supportDiagnosticsNote()) + ->and($providerSection['summary'])->toContain('provider app is missing required Microsoft Graph permissions') + ->and(collect($bundle['redaction']['markers'])->pluck('path')->all()) + ->toContain('provider_connection.credential', 'stored_reports.payload', 'audit_logs.metadata.raw') + ->and(collect($providerSection['redaction_markers'])->pluck('path')->all()) + ->toContain('provider_connection.credential'); +}); + +it('excludes raw provider payloads and unrestricted log excerpts from support bundles', function (): void { + $tenant = Tenant::factory()->create(['name' => 'Secret-Free Tenant']); + + $connection = ProviderConnection::factory() + ->withCredential() + ->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'verification_status' => ProviderVerificationStatus::Blocked->value, + 'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing, + 'last_error_message' => 'raw-provider-secret-message', + ]); + + $run = OperationRun::factory() + ->forTenant($tenant) + ->create([ + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + 'raw_response_body' => 'secret-provider-body', + ], + 'summary_counts' => [ + 'total' => 0, + 'processed' => 0, + ], + 'completed_at' => now()->subMinutes(10), + ]); + + StoredReport::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE, + 'payload' => [ + 'raw_response_body' => 'stored-report-secret-body', + ], + ]); + + AuditLog::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'action' => 'operation.failed', + 'resource_type' => 'operation_run', + 'resource_id' => (string) $run->getKey(), + 'target_label' => 'Operation #'.$run->getKey(), + 'metadata' => [ + 'raw_response_body' => 'audit-secret-body', + ], + 'outcome' => 'success', + 'recorded_at' => now()->subMinutes(5), + ]); + + $bundleJson = json_encode(app(SupportDiagnosticBundleBuilder::class)->forOperationRun($run), JSON_THROW_ON_ERROR); + + expect($bundleJson) + ->not->toContain('raw-provider-secret-message') + ->not->toContain('secret-provider-body') + ->not->toContain('stored-report-secret-body') + ->not->toContain('audit-secret-body') + ->toContain('default_redacted') + ->toContain('[REDACTED]'); +}); \ No newline at end of file diff --git a/docs/product/spec-candidates.md b/docs/product/spec-candidates.md index ee710189..4736ac8f 100644 --- a/docs/product/spec-candidates.md +++ b/docs/product/spec-candidates.md @@ -62,6 +62,7 @@ ## Promoted to Spec - Operation Run Link Contract Enforcement → Spec 232 (`operation-run-link-contract`) - Operation Run Active-State Visibility & Stale Escalation → Spec 233 (`stale-run-visibility`) - Provider Boundary Hardening → Spec 237 (`provider-boundary-hardening`) +- Support Diagnostic Pack → Spec 241 (`support-diagnostic-pack`) - Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`) - Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`) - Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`) @@ -80,20 +81,19 @@ ## Qualified > > Recommended next sequence: > -> 1. **Self-Service Tenant Onboarding & Connection Readiness** -> 2. **Support Diagnostic Pack** -> 3. **Product Usage & Adoption Telemetry** -> 4. **Operational Controls & Feature Flags** -> 5. **Private AI Execution & Policy Foundation** -> 6. **AI Usage Budgeting, Context & Result Governance** -> 7. **Decision-Based Governance Inbox v1** -> 8. **Decision Pack Contract & Approval Workflow** -> 9. **Provider Identity & Target Scope Neutrality** -> 10. **Canonical Operation Type Source of Truth** -> 11. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys** -> 12. **Customer Review Workspace v1** +> 1. **Self-Service Tenant Onboarding & Connection Readiness** (already promoted as Spec 240 on its feature branch) +> 2. **Product Usage & Adoption Telemetry** +> 3. **Operational Controls & Feature Flags** +> 4. **Private AI Execution & Policy Foundation** +> 5. **AI Usage Budgeting, Context & Result Governance** +> 6. **Decision-Based Governance Inbox v1** +> 7. **Decision Pack Contract & Approval Workflow** +> 8. **Provider Identity & Target Scope Neutrality** +> 9. **Canonical Operation Type Source of Truth** +> 10. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys** +> 11. **Customer Review Workspace v1** > -> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining product risk is not only semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage; it is also founder-dependent onboarding/support, lack of product-side observability/control, ungoverned AI introduction risk, and customer-facing search-and-troubleshoot workflows. Self-service onboarding, diagnostic packs, adoption telemetry, operational controls, private AI execution governance, and a decision-based governance inbox therefore move ahead of broader expansion so TenantPilot becomes repeatably operable, measurable, AI-ready, and safe to run with low headcount while customers receive decision-ready work instead of raw troubleshooting surfaces. +> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining product risk is not only semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage; it is also founder-dependent onboarding/support, lack of product-side observability/control, ungoverned AI introduction risk, and customer-facing search-and-troubleshoot workflows. With self-service onboarding already promoted as Spec 240 and Support Diagnostic Pack now promoted as Spec 241, adoption telemetry, operational controls, private AI execution governance, and a decision-based governance inbox become the next open priorities so TenantPilot becomes repeatably operable, measurable, AI-ready, and safe to run with low headcount while customers receive decision-ready work instead of raw troubleshooting surfaces. > Product Scalability & Self-Service Foundation cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to keep TenantPilot operable as a low-headcount, AI-assisted SaaS by productizing recurring onboarding, support, diagnostics, entitlement, help, demo, and customer-operations work. This cluster should not become a generic backoffice automation program. Only product-impacting or repeatable engineering work belongs here; pure company-ops work stays in the roadmap / operating system track. @@ -128,36 +128,6 @@ ### Self-Service Tenant Onboarding & Connection Readiness - **Strategic sequencing**: First item in this product-scalability cluster because it directly reduces manual onboarding and supports trials, demos, support, and customer transparency. - **Priority**: high -### Support Diagnostic Pack -- **Type**: product scalability / supportability foundation -- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation -- **Problem**: Support cases currently risk requiring manual context gathering across workspace, tenant, ProviderConnection, OperationRun, Finding, Report, Evidence, and audit surfaces. Without a reusable diagnostic bundle, every support request becomes an investigation task before the actual issue can be addressed. -- **Why it matters**: A low-headcount SaaS needs support context to be captured by the product, not reconstructed by the founder. Diagnostic packs also create the safe input layer for later AI-assisted support summaries and triage without granting an AI or support user broad ad-hoc access to everything. -- **Proposed direction**: - - define a support diagnostic bundle contract for workspace, tenant, OperationRun, Finding, ProviderConnection, StoredReport, and review-pack contexts - - include relevant health state, latest operation links, failure reason codes, permission/connection state, freshness, artifact references, audit references, and redacted operator summaries - - provide an AI-readable but customer-safe summary shape that can be attached to support requests - - keep raw sensitive payloads out of the default pack unless explicitly authorized - - model redaction and access checks as first-class behavior - - allow diagnostic packs to be referenced from in-app support requests and internal support workflows -- **Scope boundaries**: - - **In scope**: diagnostic pack contract, context collectors, redaction rules, support-safe summary generation, access policy, references to runs/findings/reports/evidence, and tests - - **Out of scope**: external ticket-system integration, support desk implementation, AI chat bot, broad log export, customer-visible trust center, or unrestricted raw payload download -- **Acceptance points**: - - a diagnostic pack can be generated for at least tenant and OperationRun contexts - - pack contents are deterministic, scoped, and redacted according to caller capability - - the pack links to canonical OperationRun/report/finding/evidence records instead of duplicating truth - - sensitive raw provider payloads are excluded by default - - tests prove unauthorized users cannot generate packs for unrelated workspaces/tenants -- **Risks / open questions**: - - Over-including raw context could create data-leak or compliance risk - - Under-including context would make the pack less useful and push operators back to manual investigation - - The product needs a clear capability boundary, likely related to `platform.support_diagnostics.view` and tenant/workspace support permissions -- **Dependencies**: OperationRun link contract, StoredReports / EvidenceItems, Findings workflow, ProviderConnection health, audit log foundation, System Panel least-privilege model -- **Related specs / candidates**: In-App Support Request with Context, AI-Assisted Customer Operations, System Panel Least-Privilege Capability Model, OperationRun Start UX Contract -- **Strategic sequencing**: Second item after Self-Service Tenant Onboarding; it should land before support volume grows and before AI support triage is introduced. -- **Priority**: high - ### In-App Support Request with Context - **Type**: product scalability / support workflow - **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation @@ -330,8 +300,8 @@ ### AI-Assisted Customer Operations - AI hallucination risk must be mitigated through structured inputs and source references - Privacy and data-processing boundaries need explicit review before customer data is sent to any model provider - The first version should probably be internal-only until diagnostics, knowledge, and support-request foundations are stable -- **Dependencies**: Private AI Execution & Policy Foundation, AI Usage Budgeting, Context & Result Governance, Support Diagnostic Pack, Product Knowledge & Contextual Help, In-App Support Request with Context, StoredReports / EvidenceItems, Findings workflow, release communication process, security/privacy review -- **Related specs / candidates**: Private AI Execution & Policy Foundation, AI Usage Budgeting, Context & Result Governance, Human-in-the-Loop Autonomous Governance, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Security Trust Pack Light +- **Dependencies**: Support Diagnostic Pack, Product Knowledge & Contextual Help, In-App Support Request with Context, StoredReports / EvidenceItems, Findings workflow, release communication process, security/privacy review +- **Related specs / candidates**: Human-in-the-Loop Autonomous Governance, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Security Trust Pack Light - **Strategic sequencing**: Mid-term. Do not promote before diagnostic, knowledge, and support-context foundations exist. - **Priority**: medium @@ -433,7 +403,7 @@ ### Operational Controls & Feature Flags - Operational controls must not bypass entitlement/RBAC semantics or become an untracked superpower - Too many flags can create configuration drift; start with high-risk controls only - Read-only modes need careful definition so evidence/audit access remains available -- **Dependencies**: System Panel Least-Privilege Capability Model, Provider-Backed Action Preflight and Dispatch Gate Unification, restore/provider action services, export/report services, AI execution controls, audit log foundation, Plans / Entitlements & Billing Readiness +- **Dependencies**: System Panel Least-Privilege Capability Model, Provider-Backed Action Preflight and Dispatch Gate Unification, restore/provider action services, export/report services, audit log foundation, Plans / Entitlements & Billing Readiness - **Related specs / candidates**: Provider-Backed Action Preflight and Dispatch Gate Unification, Plans / Entitlements & Billing Readiness, System Panel Least-Privilege Capability Model, Business Continuity / Founder Backup Plan - **Strategic sequencing**: High priority once external customers or pilots depend on production. Can be promoted before telemetry if incident-control risk becomes immediate. - **Priority**: high @@ -676,7 +646,7 @@ ### Decision Pack Contract & Approval Workflow - Too much context can overwhelm operators; the pack must be concise with progressive disclosure - Recommendations must not overstate certainty; confidence/freshness must be visible - AI-generated recommendations should remain optional and clearly marked until AI governance boundaries are mature -- **Dependencies**: Decision-Based Governance Inbox v1, Support Diagnostic Pack, Product Knowledge & Contextual Help, Private AI Execution & Policy Foundation, AI Usage Budgeting, Context & Result Governance, OperationRun link contract, Findings workflow, StoredReports / EvidenceItems, Operational Controls & Feature Flags, audit log foundation +- **Dependencies**: Decision-Based Governance Inbox v1, Support Diagnostic Pack, Product Knowledge & Contextual Help, OperationRun link contract, Findings workflow, StoredReports / EvidenceItems, Operational Controls & Feature Flags, audit log foundation - **Related specs / candidates**: AI-Assisted Customer Operations, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Provider-Backed Action Preflight and Dispatch Gate Unification, Customer Lifecycle Communication - **Strategic sequencing**: Should follow or pair with Governance Inbox v1. The inbox defines the work queue; decision packs make each item decision-ready. - **Priority**: high diff --git a/specs/241-support-diagnostic-pack/checklists/requirements.md b/specs/241-support-diagnostic-pack/checklists/requirements.md new file mode 100644 index 00000000..542d8b79 --- /dev/null +++ b/specs/241-support-diagnostic-pack/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Support Diagnostic Pack + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-25 +**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 on 2026-04-25 against the repo template, constitution, and spec approval rubric. +- The first slice stays explicitly narrow: tenant context and OperationRun context only, derived references only, redaction and access checks first, no new support-pack persistence, and no external helpdesk or AI behavior. \ No newline at end of file diff --git a/specs/241-support-diagnostic-pack/contracts/support-diagnostics.openapi.yaml b/specs/241-support-diagnostic-pack/contracts/support-diagnostics.openapi.yaml new file mode 100644 index 00000000..31ff979f --- /dev/null +++ b/specs/241-support-diagnostic-pack/contracts/support-diagnostics.openapi.yaml @@ -0,0 +1,284 @@ +openapi: 3.0.3 +info: + title: TenantPilot Admin — Support Diagnostic Bundle (Conceptual) + version: 0.1.0 + description: | + Conceptual HTTP contract for the first support-diagnostics slice. + + NOTE: These flows are implemented as Filament (Livewire) header actions on + existing pages. The exact Livewire request payload is not part of this + contract; this file captures the user-visible surfaces, authorization + semantics, and the derived bundle view model. +servers: + - url: /admin +paths: + /t/{tenant}/support-diagnostics/actions/open: + post: + summary: Open support diagnostics from the tenant dashboard + description: | + Read-only support-diagnostic action on the existing tenant dashboard. + + Authorization: + - Workspace non-member or non-entitled tenant actor: 404 + - Entitled tenant member without `support_diagnostics.view`: 403 + - Authorized actor: 200 with a derived, redacted support bundle + + Behavior: + - No persisted support pack is created + - Rendering stays DB-only for this slice and performs no outbound HTTP + - No provider-backed work, queue work, or new `OperationRun` is dispatched + - No raw provider payload or unrestricted log export is returned + - One redacted audit entry is recorded for bundle-open activity + parameters: + - name: tenant + in: path + required: true + schema: + type: string + description: Filament tenancy slug (`tenants.external_id`) + responses: + '200': + description: Support diagnostic preview rendered + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/SupportDiagnosticBundleView' + '403': + description: Forbidden (entitled tenant member lacks support-diagnostics capability) + '404': + description: Not found (wrong workspace, non-member, or missing tenant entitlement) + /operations/{run}/support-diagnostics/actions/open: + post: + summary: Open support diagnostics from the canonical operation detail page + description: | + Read-only support-diagnostic action on the canonical tenantless operation + detail viewer. + + Authorization: + - Inaccessible run under `OperationRunPolicy`: 404 + - Authorized member without `support_diagnostics.view`: 403 + - Authorized actor: 200 with a derived, redacted support bundle + + Behavior: + - The action is only available when the canonical run detail resolves to an entitled tenant scope + - Reuses existing humanized operation explanation and canonical links + - Rendering stays DB-only for this slice and performs no outbound HTTP + - Does not create, update, or complete an `OperationRun` + - Does not dispatch provider-backed work, queue work, or queued operation UX side effects + - Records one redacted audit entry for bundle-open activity + parameters: + - name: run + in: path + required: true + schema: + type: integer + description: Internal `operation_runs.id` + responses: + '200': + description: Support diagnostic preview rendered + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/SupportDiagnosticBundleView' + '403': + description: Forbidden (authorized member lacks support-diagnostics capability) + '404': + description: Not found (run inaccessible under workspace or tenant scope) +components: + schemas: + SupportDiagnosticBundleView: + type: object + required: + - context + - summary + - sections + - redaction + properties: + context: + $ref: '#/components/schemas/SupportDiagnosticContext' + summary: + $ref: '#/components/schemas/SupportDiagnosticSummary' + sections: + type: array + items: + $ref: '#/components/schemas/SupportDiagnosticSection' + redaction: + $ref: '#/components/schemas/SupportDiagnosticRedaction' + notes: + type: array + items: + type: string + audit: + type: object + required: + - action + - outcome + properties: + action: + type: string + outcome: + type: string + tenant_id: + type: integer + nullable: true + operation_run_id: + type: integer + nullable: true + SupportDiagnosticContext: + type: object + required: + - type + - workspace_id + properties: + type: + type: string + enum: [tenant, operation_run] + workspace_id: + type: integer + tenant_id: + type: integer + nullable: true + operation_run_id: + type: integer + nullable: true + tenant_label: + type: string + nullable: true + workspace_label: + type: string + nullable: true + SupportDiagnosticSummary: + type: object + required: + - headline + - dominant_issue + - freshness_state + - redaction_note + properties: + headline: + type: string + dominant_issue: + type: string + freshness_state: + type: string + enum: [fresh, stale, mixed, missing_context] + completeness_note: + type: string + nullable: true + redaction_note: + type: string + generated_from: + type: string + enum: [derived_existing_truth] + SupportDiagnosticSection: + type: object + required: + - key + - label + - availability + - summary + - references + properties: + key: + type: string + enum: + - overview + - provider_connection + - operation_context + - findings + - stored_reports + - tenant_review + - review_pack + - audit_history + label: + type: string + availability: + type: string + enum: [available, missing, stale, inaccessible, redacted] + summary: + type: string + freshness_note: + type: string + nullable: true + references: + type: array + items: + $ref: '#/components/schemas/SupportDiagnosticReference' + redaction_markers: + type: array + items: + $ref: '#/components/schemas/RedactionMarker' + SupportDiagnosticReference: + type: object + required: + - type + - label + - action_label + - availability + properties: + type: + type: string + enum: + - tenant + - operation_run + - provider_connection + - finding + - stored_report + - tenant_review + - review_pack + - audit_log + record_id: + type: string + nullable: true + label: + type: string + action_label: + type: string + url: + type: string + nullable: true + availability: + type: string + enum: [available, missing, inaccessible] + freshness_note: + type: string + nullable: true + access_reason: + type: string + nullable: true + SupportDiagnosticRedaction: + type: object + required: + - mode + - markers + properties: + mode: + type: string + enum: [default_redacted] + markers: + type: array + items: + $ref: '#/components/schemas/RedactionMarker' + RedactionMarker: + type: object + required: + - reason + - replacement_text + properties: + path: + type: string + nullable: true + reason: + type: string + enum: + - secret + - credential + - raw_payload + - restricted_log_excerpt + - inaccessible_record + replacement_text: + type: string \ No newline at end of file diff --git a/specs/241-support-diagnostic-pack/data-model.md b/specs/241-support-diagnostic-pack/data-model.md new file mode 100644 index 00000000..8e342282 --- /dev/null +++ b/specs/241-support-diagnostic-pack/data-model.md @@ -0,0 +1,294 @@ +# Data Model — Support Diagnostic Pack + +**Spec**: [spec.md](spec.md) + +No new persistent tables are required for the first support-diagnostics slice. The bundle is computed at request time from existing canonical records. + +## Existing Canonical Entities Reused + +### Workspace (`workspaces`) + +**Purpose**: Primary admin-plane isolation boundary and audit scope for every support-diagnostic bundle. + +**Key fields (existing)**: +- `id` +- `name` + +**Bundle use**: +- Supplies the workspace scope label. +- Anchors workspace-membership checks. +- Owns audit-log scope for bundle-open activity. + +### Tenant (`tenants`) + +**Purpose**: Tenant-plane scope boundary and the canonical subject for tenant-context support diagnostics. + +**Key fields (existing)**: +- `id` +- `workspace_id` +- `external_id` +- `name` +- `status` + +**Bundle use**: +- Acts as the primary subject for tenant-context bundles. +- Supplies tenant identity and tenant authorization scope. + +### OperationRun (`operation_runs`) + +**Purpose**: Canonical execution truth and the primary subject for operation-context support diagnostics. + +**Key fields (existing)**: +- `id` +- `workspace_id` +- `tenant_id` (nullable) +- `type` +- `status` +- `outcome` +- `summary_counts` +- `context` +- `started_at` +- `completed_at` + +**Relationships (existing)**: +- `tenant()` +- `workspace()` +- `user()` + +**Bundle use**: +- Supplies the primary execution summary. +- Carries run-bound reference ids such as `provider_connection_id` and artifact references in `context`. +- Reuses existing humanized run explanation and canonical run URLs. + +### ProviderConnection (`provider_connections`) + +**Purpose**: Canonical provider readiness and connection state for the tenant or run context. + +**Key fields (existing)**: +- `id` +- `workspace_id` +- `tenant_id` +- `provider` +- `connection_type` +- `consent_status` +- `verification_status` +- `last_error_reason_code` +- `last_error_message` +- `is_default` +- `is_enabled` +- `last_health_check_at` + +**Relationships (existing)**: +- `tenant()` +- `workspace()` +- `credential()` + +**Bundle use**: +- Supplies provider readiness summary, translated provider failure reasons, and target-scope detail. +- Never contributes raw credential payloads or secrets to the bundle. + +### Finding (`findings`) + +**Purpose**: Canonical drift or permission posture issues that may explain current support pressure. + +**Key fields (existing)**: +- `id` +- `workspace_id` +- `tenant_id` +- `type` +- `severity` +- `status` +- `baseline_operation_run_id` +- `current_operation_run_id` +- `due_at` +- `last_seen_at` + +**Relationships (existing)**: +- `tenant()` +- `baselineRun()` +- `currentRun()` +- `findingException()` + +**Bundle use**: +- Supplies prioritized open or recent findings relevant to the current tenant or run. +- Contributes summary and freshness cues only; finding detail remains on canonical pages. + +### StoredReport (`stored_reports`) + +**Purpose**: Canonical report/evidence truth for report identity and freshness. + +**Key fields (existing)**: +- `id` +- `workspace_id` +- `tenant_id` +- `report_type` +- `fingerprint` +- `previous_fingerprint` +- `payload` + +**Relationships (existing)**: +- `workspace()` +- `tenant()` + +**Bundle use**: +- Supplies report identity, report type, and freshness/continuity cues. +- The bundle must not expose the full stored report payload by default. + +### TenantReview (`tenant_reviews`) + +**Purpose**: Canonical tenant review state and review-level summary for governance follow-up. + +**Key fields (existing)**: +- `id` +- `workspace_id` +- `tenant_id` +- `operation_run_id` +- `status` +- `completeness_state` +- `summary` +- `generated_at` +- `current_export_review_pack_id` + +**Relationships (existing)**: +- `workspace()` +- `tenant()` +- `operationRun()` +- `reviewPacks()` + +**Bundle use**: +- Supplies current review status, completeness, blockers, and canonical review references when review truth is relevant. + +### ReviewPack (`review_packs`) + +**Purpose**: Canonical review export/package truth when a tenant review already has a pack. + +**Key fields (existing)**: +- `id` +- `workspace_id` +- `tenant_id` +- `operation_run_id` +- `tenant_review_id` +- `status` +- `summary` +- `generated_at` +- `expires_at` +- `file_size` + +**Relationships (existing)**: +- `workspace()` +- `tenant()` +- `operationRun()` +- `tenantReview()` + +**Bundle use**: +- Supplies pack availability, readiness, and expiry cues. +- The bundle links to the canonical pack viewer instead of reproducing pack content. + +### AuditLog (`audit_logs`) + +**Purpose**: Canonical audit trail for workspace-, tenant-, and operation-related events. + +**Key fields (existing)**: +- `id` +- `workspace_id` +- `tenant_id` +- `action` +- `resource_type` +- `resource_id` +- `target_label` +- `metadata` +- `outcome` +- `operation_run_id` +- `recorded_at` + +**Relationships (existing)**: +- `tenant()` +- `workspace()` +- `operationRun()` + +**Bundle use**: +- Supplies the most relevant authorized audit references for the current tenant or run. +- Also records bundle-open activity with redacted metadata only. + +## Derived Runtime Entities + +### SupportDiagnosticBundle (computed, not persisted) + +**Purpose**: One machine-readable, redacted support-safe envelope for either a tenant context or an operation-run context. + +**Proposed shape (runtime array / view-model)**: +- `context_type` — `tenant` or `operation_run` +- `workspace` — workspace reference and label +- `tenant` — tenant reference when applicable +- `operation_run` — primary run reference when applicable +- `headline` — dominant support summary +- `dominant_issue` — translated blocker or issue statement +- `freshness_state` — derived cue such as `fresh`, `stale`, `mixed`, or `missing_context` +- `redaction_mode` — fixed first-slice mode: default-redacted +- `sections` — ordered list of section payloads +- `notes` — explicit redaction, completeness, or degradation notes + +**Relationships**: +- 1 workspace +- 0..1 tenant +- 0..1 primary operation run +- 0..1 provider connection section +- 0..n finding references +- 0..n stored report references +- 0..1 tenant review reference +- 0..1 review pack reference +- 0..n audit references + +### SupportDiagnosticSection (computed, not persisted) + +**Purpose**: One deterministic section inside the bundle. + +**Proposed shape**: +- `key` — fixed section key such as `provider_connection`, `operation_context`, `findings`, `stored_reports`, `tenant_review`, `review_pack`, `audit_history` +- `label` +- `availability` — derived local status (`available`, `missing`, `stale`, `inaccessible`, `redacted`) +- `summary` +- `freshness_at` or `freshness_note` +- `references` — ordered support references for that section +- `redaction_markers` — explicit markers when detail is intentionally excluded + +**Note**: these are presentation-contract values only, not new persisted domain state. + +### SupportDiagnosticReference (computed, not persisted) + +**Purpose**: Stable canonical link/reference metadata for one related record. + +**Proposed shape**: +- `type` — `tenant`, `operation_run`, `provider_connection`, `finding`, `stored_report`, `tenant_review`, `review_pack`, or `audit_log` +- `record_id` +- `label` +- `action_label` +- `url` (nullable when inaccessible or no viewer exists) +- `availability` +- `freshness_note` (nullable) +- `access_reason` or `missing_reason` (nullable) + +### RedactionMarker (computed, not persisted) + +**Purpose**: Make exclusion deterministic and explicit. + +**Proposed shape**: +- `path` (nullable) +- `reason` — `secret`, `credential`, `raw_payload`, `restricted_log_excerpt`, or `inaccessible_record` +- `replacement_text` + +## Derived Rules and Validation + +- Bundle generation requires established workspace membership first. +- Tenant-context bundle generation requires tenant entitlement before any tenant-owned section resolves. +- Operation-context bundle generation must first pass `OperationRunPolicy::view(...)`; if the run points at a tenant, tenant entitlement still applies before tenant-owned related records resolve. +- After membership/entitlement is established, missing `support_diagnostics.view` is a `403` capability denial. +- The bundle must never include secrets, tokens, credentials, unrestricted provider response bodies, or unrestricted stored-report payloads. +- Missing or inaccessible related records must degrade into explicit placeholders or unavailable references, not hard-fail the whole bundle. +- For unchanged authorized input, section order, reference order, and redaction output must remain stable. + +## Lifecycle and Audit Behavior + +- `SupportDiagnosticBundle` has no persisted lifecycle. +- Opening the bundle writes one audit event with redacted metadata only. +- Opening linked canonical records follows their own existing authorization and audit behavior. \ No newline at end of file diff --git a/specs/241-support-diagnostic-pack/plan.md b/specs/241-support-diagnostic-pack/plan.md new file mode 100644 index 00000000..21768765 --- /dev/null +++ b/specs/241-support-diagnostic-pack/plan.md @@ -0,0 +1,214 @@ +# Implementation Plan: Support Diagnostic Pack + +**Branch**: `241-support-diagnostic-pack` | **Date**: 2026-04-25 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +- Add one derived, support-safe diagnostic bundle contract that can be opened from exactly two existing admin-plane surfaces: the tenant dashboard and the canonical tenantless operation detail viewer. +- Reuse current canonical truth and shared paths instead of inventing support-local persistence or navigation: `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack`, `AuditLog`, `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, and the existing audit recorder. +- Keep the first slice strictly read-only and deterministic: no support-desk workflow, no external ticketing, no raw payload export, no persisted support-pack entity, no new `OperationRun` type, no notification changes, and no AI runtime behavior. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` +**Storage**: PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned +**Testing**: Pest unit + feature tests only +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Sail-backed Laravel admin panel under `/admin` and `/admin/t/{tenant}` +**Project Type**: web +**Performance Goals**: bundle collection and preview remain DB-only at render/action time, perform no outbound HTTP, create no new `OperationRun`, and deterministically compose the same result for unchanged authorized input +**Constraints**: derive-before-persist, provider-neutral top-level language, deterministic redaction, no raw payload/body export, no support-desk product, no external ticketing, no AI runtime features, no new run lifecycle semantics, no browser/heavy-governance drift, and RBAC non-member `404` versus capability `403` boundaries must remain explicit +**Scale/Scope**: one bundle contract, two entry contexts (`TenantDashboard` and `TenantlessOperationRunViewer`), existing canonical record references only, and focused unit + feature coverage + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament + shared read-only preview composition +- **Shared-family relevance**: header actions, diagnostic summaries, related links, audit drill-throughs +- **State layers in scope**: page, detail, action preview +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: standard-native-filament, monitoring-state-page +- **Required tests or manual smoke**: functional-core +- **Exception path and spread control**: the existing tenant dashboard action-surface exemption remains the only dashboard exception; no new support page, queue, or custom interaction family is introduced +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `App\Filament\Pages\TenantDashboard`, `App\Filament\Pages\Operations\TenantlessOperationRunViewer`, `App\Support\OperationRunLinks`, `App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder`, `App\Support\Providers\ProviderReasonTranslator`, `App\Support\Navigation\RelatedNavigationResolver`, `App\Support\RedactionIntegrity`, canonical review/provider/finding/audit viewers, and `App\Services\Audit\WorkspaceAuditLogger` +- **Shared abstractions reused**: `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `ProviderConnectionSurfaceSummary`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`, `AuditActionId` +- **New abstraction introduced? why?**: one narrow `SupportDiagnosticBundleBuilder` under `App\Support\SupportDiagnostics` is justified because there are now two concrete contexts that must emit the same machine-readable, redacted bundle contract. It should return one documented array shape, not a registry, strategy layer, or persisted model. +- **Why the existing abstraction was sufficient or insufficient**: existing helpers already own canonical links, run explanation language, provider reason translation, and redaction-note wording; what is missing is a deterministic cross-record composition layer that assembles those existing truths into one support-safe bundle. +- **Bounded deviation / spread control**: preview stays on the two existing pages only, reuses current labels and URLs, and explicitly forbids a standalone support resource, export pipeline, or page-local link dialect. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes, for deep-link and explanation reuse only +- **Central contract reused**: `OperationRunLinks` and the existing canonical tenantless run viewer, plus `GovernanceRunDiagnosticSummaryBuilder` for run explanation reuse +- **Delegated UX behaviors**: canonical `Open operation` labeling, tenant/workspace-safe run URL resolution, shared run explanation language, and existing related-record link labels +- **Surface-owned behavior kept local**: one read-only `Open support diagnostics` action plus preview/detail composition on the tenant dashboard and run-detail surfaces +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes +- **Provider-owned seams**: provider connection descriptors, consent/permission failure excerpts, translated provider error detail +- **Platform-core seams**: bundle shell, context labels, section ordering, freshness cues, redaction semantics, and canonical-reference vocabulary +- **Neutral platform terms / contracts preserved**: `support diagnostic bundle`, `tenant context`, `operation context`, `provider connection`, `related record`, `audit reference`, `redaction reason` +- **Retained provider-specific semantics and why**: Microsoft-specific permission, consent, and provider-failure wording stays only inside translated provider-owned summaries produced by `ProviderReasonTranslator`, because the current operator still needs exact remediation context when provider readiness is the blocker +- **Bounded extraction or follow-up path**: none in this slice; any future System Panel least-privilege support surface remains a separate follow-up spec instead of widening this tenant/admin-plane bundle contract + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Derive-before-persist / `PERSIST-001`: PASS — no `support_diagnostic_packs` table or persisted entity is introduced; the bundle is composed at request time from existing workspace, tenant, run, provider, finding, report, review, and audit truth. +- Proportionality / `PROP-001` and `ABSTR-001`: PASS — one narrow builder is justified by exactly two real contexts (tenant and run) that must share one deterministic contract; no registry, resolver lattice, or support framework is planned. +- Provider boundary / `PROV-001`: PASS — top-level bundle fields stay provider-neutral, while provider-specific semantics remain in provider-owned translated detail. +- Shared path reuse / `XCUT-001`: PASS — the feature reuses `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `ProviderConnectionSurfaceSummary`, `RelatedNavigationResolver`, and `RedactionIntegrity` instead of creating a second support vocabulary. +- RBAC, workspace isolation, tenant isolation / `RBAC-UX`: PASS — tenant dashboard access still requires established workspace + tenant scope, `support_diagnostics.view` is scoped through the canonical tenant capability registry and tenant role map, the run-detail action only applies when `OperationRunPolicy` has already resolved an entitled tenant scope, non-members or non-entitled actors stay `404`, and an entitled actor lacking the new capability receives `403` on the action. +- Read/write separation and auditability: PASS — the bundle is read-only, writes no Graph or product state, and logs bundle-open activity via the existing audit recorder with redacted metadata only. +- Graph contract path: PASS — no new Graph calls are added; render and action hydration remain DB-only. +- OperationRun lifecycle / Ops-UX: PASS — the feature does not create, update, or complete runs; it only reuses existing run explanation and link semantics. +- Global search / Filament resource requirements: `N/A` — no new global-searchable Filament resource is introduced. +- Panel/provider registration: `N/A` — no panel or provider changes are planned; Laravel 12 provider registration remains in `bootstrap/providers.php`. +- Test governance / `TEST-GOV-001`: PASS — proof stays in focused unit + feature lanes, with no browser or heavy-governance family growth. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for bundle assembly, stable ordering, redaction, and related-reference shaping; Feature for tenant dashboard action behavior, operation-detail action behavior, audit logging, authorization boundaries, and no-side-effect execution proof +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: the feature is a server-driven Filament/Livewire read-only action over existing database truth. Unit tests can prove determinism and redaction logic directly, while Feature tests can prove action registration, `404`/`403` boundaries, rendered canonical links, audit logging, and the absence of provider-backed or run-creating side effects without browser duplication. +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php` +- **Fixture / helper / factory / seed / context cost risks**: reuse existing `Workspace`, `Tenant`, `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack`, and `AuditLog` factories; add only one opt-in support-diagnostic seeding helper local to these tests so browser or full provider fixtures do not become defaults. +- **Expensive defaults or shared helper growth introduced?**: no; support-diagnostic test helpers must stay local and opt-in. +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: standard-native-filament plus monitoring-state-page coverage is sufficient; assert the action and the resulting preview content rather than browser-level modal choreography. +- **Closing validation and reviewer handoff**: rerun the targeted unit and feature commands after implementation; reviewers should confirm `404` versus `403`, explicit redaction markers, deterministic section ordering, canonical link reuse, absence of raw payload or token leakage, and that opening diagnostics creates no new `OperationRun`, Ops UX side effect, or provider-backed work. +- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep +- **Review-stop questions**: did the implementation introduce a standalone support page/resource, a persisted pack, a raw export path, or browser-only proof? did it bypass existing link/explanation helpers? did any test silently widen into a heavy family? +- **Escalation path**: `reject-or-split` if implementation adds persistence, export, support-desk workflow, or system-plane capability expansion; `document-in-feature` for small shared-helper extensions that remain local to this slice +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: the planned unit and feature tests extend existing fixture families and do not create a new recurring governance or browser cost center. + +## Project Structure + +### Documentation (this feature) + +```text +specs/241-support-diagnostic-pack/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── checklists/ +│ └── requirements.md +├── contracts/ +│ └── support-diagnostics.openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/Pages/TenantDashboard.php +│ ├── Filament/Pages/Operations/TenantlessOperationRunViewer.php +│ ├── Policies/OperationRunPolicy.php +│ ├── Services/Audit/WorkspaceAuditLogger.php +│ ├── Support/Audit/AuditActionId.php +│ ├── Support/Auth/Capabilities.php +│ ├── Support/Navigation/RelatedNavigationResolver.php +│ ├── Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php +│ ├── Support/OperationRunLinks.php +│ ├── Support/Providers/ProviderReasonTranslator.php +│ ├── Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php +│ ├── Support/RedactionIntegrity.php +│ └── Support/SupportDiagnostics/ +│ └── SupportDiagnosticBundleBuilder.php +└── tests/ + ├── Feature/SupportDiagnostics/ + │ ├── TenantSupportDiagnosticActionTest.php + │ ├── OperationRunSupportDiagnosticActionTest.php + │ ├── SupportDiagnosticAuthorizationTest.php + │ └── SupportDiagnosticAuditTest.php + └── Unit/Support/SupportDiagnostics/ + ├── SupportDiagnosticBundleBuilderTest.php + └── SupportDiagnosticBundleRedactionTest.php +``` + +**Structure Decision**: Single Laravel web application. The feature stays inside existing tenant and monitoring detail pages plus one narrowly scoped support-diagnostics builder; no new resource, panel, route group, or persistence layer is introduced. + +## Complexity Tracking + +No constitution violations are required for this plan. The only new structural element is one bounded derived bundle builder, justified below and kept within current-release truth. + +## Proportionality Review + +- **Current operator problem**: founder-led support still starts with manual cross-page reconstruction across tenant, provider, run, finding, report, review, and audit truth before the real issue can be understood. +- **Existing structure is insufficient because**: current pages explain their own local truth, but there is no support-safe, deterministic composition layer that can gather those existing truths into one reusable bundle without page-local drift. +- **Narrowest correct implementation**: one read-only bundle builder plus one read-only action on each of the two existing surfaces, reusing existing helpers, canonical links, and audit logging. No new entity, no export pipeline, no support queue, and no additional notification behavior. +- **Ownership cost created**: one array-shaped contract, one capability constant and role mapping entry, one audit action identifier, and focused unit + feature coverage. +- **Alternative intentionally rejected**: a persisted `SupportDiagnosticPack` model, a standalone support page/resource, and raw payload export/download were rejected as premature persistence and over-broad workflow expansion; page-local copy helpers were rejected as too narrow and drift-prone. +- **Release truth**: current-release truth + +## Phase 0 — Research (output: `research.md`) + +See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/research.md` + +Goals: +- Confirm the narrowest existing sources of truth for tenant, run, provider, finding, report, review-pack, and audit references. +- Resolve the capability and product-candidate open question by keeping support diagnostics on already-authorized tenant/admin surfaces only, with no new System Panel visibility bypass in this slice. +- Confirm that redaction, related navigation, and run explanation can stay on existing shared helpers instead of introducing a second support-local layer. +- Define deterministic ordering and graceful degradation rules for missing, stale, or inaccessible related records. + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +See: +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/data-model.md` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/contracts/support-diagnostics.openapi.yaml` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/quickstart.md` + +Design focus: +- Add one read-only `Open support diagnostics` header action to `TenantDashboard` and `TenantlessOperationRunViewer` only. +- Compose a tenant-context bundle from existing tenant truth, current relevant provider connection state, the most relevant recent operation context, open or recent findings, latest stored-report freshness, latest tenant-review or review-pack reference when present, and authorized audit references. +- Compose an operation-context bundle from the existing humanized run summary plus related provider, finding, report, review, and audit references without changing `OperationRun` lifecycle semantics. +- Keep section order and reference ordering deterministic, with explicit `missing`, `stale`, `redacted`, or `inaccessible` cues instead of silent omission. +- Reuse canonical navigation and explanation helpers so every link, label, and run explanation means the same thing elsewhere in the product. +- Add one tenant-plane capability in the canonical registry and tenant role map for bundle access, keep system-plane support diagnostics out of scope, only expose the run-detail action when the referenced run resolves to an entitled tenant scope, and record bundle-open activity with redacted metadata only. +- Keep preview composition native to Filament read-only actions or infolist-style rendering on existing pages. No standalone support page or export/download action is planned. + +## Phase 1 — Agent Context Update + +After Phase 1 artifacts are generated, update Copilot context from the plan: + +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot` + +## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`) + +- Introduce `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder` that exposes explicit `forTenant(...)` and `forOperationRun(...)` entry points and returns one documented derived bundle shape. +- Wire a read-only `Open support diagnostics` action into `App\Filament\Pages\TenantDashboard` and populate the preview from authorized tenant truth only. +- Wire the same read-only action into `App\Filament\Pages\Operations\TenantlessOperationRunViewer`, reusing `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunLinks`, and existing related-navigation helpers. +- Add the dedicated support-diagnostics capability to the canonical tenant capability registry and tenant role map, then enforce it server-side on both actions after membership/entitlement is established and after the run-detail surface has resolved an entitled tenant scope. +- Add an audit action identifier and record bundle-open activity with redacted metadata through the existing audit recorder, without storing excluded payload content. +- Add focused unit tests for deterministic ordering and redaction plus focused feature tests for tenant/run action behavior, `404`/`403` boundaries, canonical links, and audit logging. + +## Constitution Check (Post-Design) + +Re-check result: PASS. The design remains derived, provider-neutral at the shared boundary, anchored on existing shared link and explanation paths, explicit about `404` versus `403`, bounded to two read-only surfaces, and contained to focused unit + feature proof with no browser or support-desk drift. + +## Implementation Close-out + +- Guardrail close-out: PASS. The implementation remained read-only, DB-only at render/action time, and did not create new `OperationRun` records or dispatch provider-backed or queued operation work. +- Validation lanes passed: targeted unit coverage for deterministic ordering and redaction plus targeted feature coverage for tenant action, run action, authorization boundaries, and audit/no-side-effect guarantees. +- Shared-helper note: no follow-up spec was required. The final slice stayed on existing `OperationRunPolicy`, `OperationRunLinks`, `RelatedNavigationResolver`, `GovernanceRunDiagnosticSummaryBuilder`, `RedactionIntegrity`, and `WorkspaceAuditLogger` seams with one bounded `SupportDiagnosticBundleBuilder` abstraction. diff --git a/specs/241-support-diagnostic-pack/quickstart.md b/specs/241-support-diagnostic-pack/quickstart.md new file mode 100644 index 00000000..35237d41 --- /dev/null +++ b/specs/241-support-diagnostic-pack/quickstart.md @@ -0,0 +1,46 @@ +# Quickstart — Support Diagnostic Pack + +## Prereqs + +- Docker running +- Laravel Sail dependencies installed +- Existing workspace, tenant, run, provider, finding, report, review, and audit factories available for tests + +## Run locally + +- Start containers: `cd apps/platform && ./vendor/bin/sail up -d` +- Run targeted tests after implementation: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php` +- Format after implementation: `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## Manual smoke after implementation + +1. Sign in to `/admin` as a workspace member with tenant entitlement and the new tenant-plane `support_diagnostics.view` capability. +2. Open one tenant at `/admin/t/{tenant}` and trigger `Open support diagnostics`. +3. Confirm the preview shows a deterministic summary, redaction note, canonical related links, and no raw provider payload or credential detail. +4. Open one canonical operation detail at `/admin/operations/{run}` for a run that resolves to the same entitled tenant scope and trigger the same action. +5. Confirm the run summary reuses the existing operation explanation language and that related links still open canonical provider, finding, review, review-pack, and audit surfaces. +6. Verify a non-member or non-entitled actor receives `404`, while an entitled actor without the support-diagnostics capability sees the action disabled in UI and receives `403` on direct action execution. +7. Verify an audit entry is recorded for bundle-open activity with redacted metadata only. +8. Verify opening diagnostics stays DB-only in this slice: no new `OperationRun` is created, no provider-backed work is dispatched, and no queued operation UX side effect appears. + +## Notes + +- Filament v5 remains on Livewire v4.0+ in this repo; the feature stays within native Filament page actions and read-only preview composition. +- No panel provider changes are planned; Laravel 12 provider registration remains in `bootstrap/providers.php`. +- No global-search behavior changes are involved because this slice does not add a new Filament resource. +- No destructive actions are introduced; `Open support diagnostics` remains a read-only action. +- The new `support_diagnostics.view` gate is tenant-role scoped on tenant-admin surfaces; workspace-owned and system-plane runs remain out of scope for this first slice. + +## Implementation Close-out + +- Guardrail result: PASS +- Latest targeted validation passed: + - `tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php` + - `tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php` + - `tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php` + - `tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php` + - `tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php` + - `tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php` +- Shared-helper note: no follow-up spec is required for this slice; the implementation stayed on existing `OperationRunPolicy`, `OperationRunLinks`, `RelatedNavigationResolver`, `GovernanceRunDiagnosticSummaryBuilder`, `RedactionIntegrity`, and `WorkspaceAuditLogger` paths. \ No newline at end of file diff --git a/specs/241-support-diagnostic-pack/research.md b/specs/241-support-diagnostic-pack/research.md new file mode 100644 index 00000000..9f880272 --- /dev/null +++ b/specs/241-support-diagnostic-pack/research.md @@ -0,0 +1,140 @@ +# Research — Support Diagnostic Pack + +**Date**: 2026-04-25 +**Spec**: [spec.md](spec.md) + +This document captures design decisions and supporting rationale for the first support-diagnostics slice. All decisions are grounded in current repository truth and the TenantPilot Constitution. + +## Decision 1 — The support diagnostic bundle stays derived and read-only + +**Decision**: Keep the first slice as a derived bundle assembled at request time from existing records. Do not introduce a persisted `SupportDiagnosticPack` model, table, queue, or export artifact. + +**Rationale**: +- The operator problem is fast support-safe context gathering, not a new long-lived domain object. +- Constitution `PERSIST-001` and `PROP-001` require derive-before-persist when current-release truth does not need an independent lifecycle. +- The bundle needs deterministic structure and auditability, but neither requires a new stored entity in this slice. + +**Evidence**: +- Existing canonical truth already exists on `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack`, and `AuditLog`. +- The spec explicitly forbids a persisted support-pack entity and raw export pipeline. + +**Alternatives considered**: +- Persist a generated support pack with its own lifecycle. + - Rejected: introduces new truth, retention concerns, and export expectations the first slice does not need. +- Add page-local copy/export helpers only. + - Rejected: too narrow and drift-prone, and fails the cross-surface reuse goal. + +## Decision 2 — Reuse the two existing entry surfaces instead of creating a new support page + +**Decision**: Add read-only `Open support diagnostics` actions to `TenantDashboard` and `TenantlessOperationRunViewer` only. Do not create a standalone Filament resource or route. + +**Rationale**: +- The user already reaches support context from an existing tenant or run workflow. +- Constitution `DECIDE-001`, `UI-FIL-001`, and the spec’s surface contract require support diagnostics to stay secondary or tertiary, not become a new queue or product area. +- Existing surfaces already establish the correct workspace/tenant/run authorization context. + +**Evidence**: +- Tenant entry surface: `apps/platform/app/Filament/Pages/TenantDashboard.php` +- Canonical run detail surface: `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` +- The run detail page already owns scope, back navigation, refresh, and grouped related links. + +**Alternatives considered**: +- Create `/admin/support-diagnostics/...` pages. + - Rejected: wider surface area, new navigation semantics, and unnecessary support-product expansion. + +## Decision 3 — Add one tenant/admin-plane support capability, but do not widen tenant visibility + +**Decision**: Introduce one tenant/admin-plane capability, `support_diagnostics.view`, in the canonical tenant capability registry and role mapping. Keep support diagnostics available only on already-authorized tenant and run surfaces. Do not add any System Panel support-diagnostics capability in this slice. + +**Rationale**: +- The spec requires a dedicated support-diagnostics capability and strict `404` vs `403` boundaries. +- The roadmap’s open question about support diagnostics revealing tenant metadata without tenant-directory access is resolved narrowly here: this slice does not create a new metadata visibility path. It only augments already-authorized admin-plane surfaces. +- That keeps current tenant/workspace isolation intact and defers platform-plane least-privilege splitting to the separate system-panel RBAC work. + +**Evidence**: +- Current tenant/admin capability registry: `apps/platform/app/Support/Auth/Capabilities.php` +- Current run authorization semantics: `apps/platform/app/Policies/OperationRunPolicy.php` +- Product candidate open question on tenant metadata visibility: `docs/product/spec-candidates.md` + +**Alternatives considered**: +- Reuse an existing broad capability such as `tenant.view` or `audit.view`. + - Rejected: too coarse for a support-safe bundle that aggregates multiple record types. +- Add a platform/system-plane support capability now. + - Rejected: outside the current admin-plane slice and would broaden scope into least-privilege platform RBAC. + +## Decision 4 — Existing shared helpers remain authoritative for labels, links, and run explanation + +**Decision**: The bundle builder must reuse `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `ProviderConnectionSurfaceSummary`, `RelatedNavigationResolver`, and `RedactionIntegrity` instead of creating support-local link builders or explanation text. + +**Rationale**: +- Constitution `XCUT-001` requires reuse of the existing shared path for cross-cutting interaction classes. +- The support bundle should not create a second vocabulary for operation wording, provider readiness, or related-record labels. +- Reuse reduces drift and makes future AI-safe consumers rely on the same canonical operator phrasing. + +**Evidence**: +- Canonical run link helpers: `apps/platform/app/Support/OperationRunLinks.php` +- Humanized run diagnostic summaries: `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` +- Provider reason translation: `apps/platform/app/Support/Providers/ProviderReasonTranslator.php` +- Related-record navigation and audit target linking: `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php` +- Existing redaction note wording: `apps/platform/app/Support/RedactionIntegrity.php` + +**Alternatives considered**: +- Build support-specific strings and URLs inside each page action. + - Rejected: duplicated semantics, harder review, and immediate drift risk. + +## Decision 5 — Deterministic ordering is part of the bundle contract, not a UI afterthought + +**Decision**: Fix one section order for every bundle and sort references by stable business signals first, then stable record identity. Prefer run-bound references when the current run explicitly identifies them; otherwise fall back to the current tenant’s latest authorized canonical records. + +**Rationale**: +- The spec requires that the same authorized input and unchanged source truth produce the same section order, reference order, and redaction output. +- Incidental query order would make later AI consumption and regression testing unreliable. +- This decision can stay local to the bundle builder and documented contract without adding a new enum or persistence layer. + +**Expected ordering**: +- Section order: overview, provider connection, operation context, findings, stored reports, tenant review, review pack, audit history. +- Reference ordering: prefer run-bound reference first; otherwise order by the relevant lifecycle timestamp (`recorded_at`, `generated_at`, `last_seen_at`, `completed_at`, `id`) with deterministic tie-breakers. + +**Evidence**: +- `AuditLog` already defines a canonical latest-first ordering via `scopeLatestFirst()`. +- `ReviewPack`, `TenantReview`, and `OperationRun` expose stable lifecycle timestamps and latest-first retrieval patterns. + +**Alternatives considered**: +- Let each section use its default Eloquent query order. + - Rejected: not deterministic enough for the contract promised by the spec. + +## Decision 6 — Audit bundle-open activity through the existing recorder with redacted metadata only + +**Decision**: Record bundle-open activity through `WorkspaceAuditLogger` with a new audit action identifier. Include actor, workspace, tenant when present, primary context type/id, redaction mode, and section/reference counts. Exclude raw provider payloads, secrets, tokens, and unrestricted log excerpts. + +**Rationale**: +- The feature is read-only, but support-diagnostics access is still security- and audit-relevant. +- `WorkspaceAuditLogger` already supports workspace-scoped and tenant-scoped audit entries without introducing a support-specific persistence path. +- Redacted metadata is sufficient for evidence and review. + +**Evidence**: +- Workspace-scoped audit writer: `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` +- Canonical audit action IDs: `apps/platform/app/Support/Audit/AuditActionId.php` + +**Alternatives considered**: +- Skip auditing because the action is read-only. + - Rejected: contradicts the spec’s auditability requirement. +- Persist the full generated bundle in the audit log. + - Rejected: leaks excluded data and violates the derive-before-persist goal. + +## Decision 7 — Proof stays in Unit + Feature lanes only + +**Decision**: Keep proof in focused unit and feature suites. Do not introduce browser coverage, heavy-governance flows, or new lane families for this slice. + +**Rationale**: +- The business truth is deterministic bundle composition plus server-side authorization, redaction, canonical link reuse, and audit logging. +- Browser tests would mostly duplicate modal/preview rendering and slow down the narrow support slice. +- Constitution `TEST-GOV-001` requires the narrowest proving lane mix. + +**Evidence**: +- Existing feature coverage already exercises tenant dashboard, canonical run detail, monitoring navigation, audit visibility, and operation-link contracts. +- Existing unit coverage already protects shared helpers such as run summaries and navigation resolvers. + +**Alternatives considered**: +- Add browser smoke tests for modal interaction. + - Rejected: browser proof is not necessary to establish the core business truth of this first slice. \ No newline at end of file diff --git a/specs/241-support-diagnostic-pack/spec.md b/specs/241-support-diagnostic-pack/spec.md new file mode 100644 index 00000000..663a753c --- /dev/null +++ b/specs/241-support-diagnostic-pack/spec.md @@ -0,0 +1,297 @@ +# Feature Specification: Support Diagnostic Pack + +**Feature Branch**: `241-support-diagnostic-pack` +**Created**: 2026-04-25 +**Status**: Draft +**Input**: User description: "Support Diagnostic Pack" + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: Support and troubleshooting work still requires manual context gathering across workspace, tenant, ProviderConnection, OperationRun, Finding, StoredReport, TenantReview, review artifacts, and audit history before the real issue can be addressed. +- **Today's failure**: A founder or support-capable operator must reconstruct the case by hand, which slows first response, increases the chance of oversharing sensitive provider context, and leaves later AI-assisted support without a safe canonical input layer. +- **User-visible improvement**: An entitled operator can open one deterministic, redacted diagnostic bundle for a tenant or one operation run and immediately see the current issue, freshness, and the right canonical records to inspect next. +- **Smallest enterprise-capable version**: A read-only, derived diagnostic bundle contract for tenant context and OperationRun context that references existing canonical records, applies first-class redaction and access checks, and writes audit entries for bundle generation without creating a new persisted support-pack entity. +- **Explicit non-goals**: No external ticketing or helpdesk integration, no support-desk product, no unrestricted raw payload export or download, no broad log export pipeline, no AI chatbot or autonomous AI support behavior, and no application implementation in this preparation artifact. +- **Permanent complexity imported**: One derived support-diagnostic bundle contract, one deterministic ordering and redaction policy, two bounded action entry points, and focused unit and feature coverage for authorization, redaction, and reference continuity. +- **Why now**: Self-Service Tenant Onboarding & Connection Readiness already exists as Spec 240, so the next supportability bottleneck is founder-led troubleshooting. This slice is the next recommended candidate, directly reduces manual support work, and reuses strong existing foundations that are already in the repo. +- **Why not local**: Page-local exports or copy helpers on one tenant or run page would duplicate truth, drift labels and redaction behavior, and still fail to provide one reusable support-safe contract across support workflows. +- **Approval class**: Core Enterprise +- **Red flags triggered**: Two red flags apply: it sounds like a foundation/support layer, and it touches more than one surface. Defense: the first slice is tightly limited to tenant context and OperationRun context, introduces no persistence, composes existing record truth instead of inventing a helpdesk framework, and explicitly defers broader support product work. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace +- **Primary Routes**: + - `/admin/t/{tenant}` as the first tenant-context entry point for a tenant-scoped support diagnostic bundle + - `/admin/operations/{run}` as the first OperationRun-context entry point for a run-scoped support diagnostic bundle + - Existing related destinations reached from the bundle, including tenant findings, provider connection detail, tenant review detail, review-pack detail when present, and audit-log event detail +- **Data Ownership**: No new `support_diagnostic_packs` truth is introduced. The bundle is derived from existing canonical records. Source truth remains on workspace, tenant, `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack` when present, and `AuditLog` references tied to the same authorized scope. +- **RBAC**: Workspace membership is required, and both entry surfaces stay in the tenant-admin `/admin` plane. Tenant entitlement is required before any tenant-owned record is resolved. A dedicated tenant-role capability `support_diagnostics.view` in the canonical capability registry and tenant role map gates bundle generation and bundle viewing on both entry surfaces. The tenantless operation viewer only offers the action when the referenced run resolves to an entitled tenant scope in this slice; workspace-owned or system-plane runs remain out of scope. Existing per-record permissions still govern whether linked canonical records may be opened after the bundle is shown. + +For canonical-view specs, the spec MUST define: + +- Not applicable as a primary scope because the first slice adds support-diagnostic actions to existing tenant and operation-detail surfaces rather than introducing a new canonical collection page. + +## 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 +- **Interaction class(es)**: header actions, diagnostic summaries, related-record links, evidence and report references, audit drill-throughs +- **Systems touched**: canonical operation related links, governance run explanation summaries, provider reason translation, existing resource detail routes, and audit-log detail resolution +- **Existing pattern(s) to extend**: existing operation related-link behavior, existing humanized operation explanation behavior, existing tenant-safe related-record routing, and existing audit-log navigation +- **Shared contract / presenter / builder / renderer to reuse**: `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, and existing tenant-safe record viewers for findings, provider connections, tenant reviews, review packs, and audit-log events +- **Why the existing shared path is sufficient or insufficient**: The existing paths are sufficient for labels, links, and run explanation language, but they do not yet assemble one support-safe cross-record bundle with deterministic ordering and redaction. +- **Allowed deviation and why**: One new derived bundle assembler is allowed, but it may only compose existing truth and existing shared helpers. Parallel page-local link builders, raw payload embedding, or a second support-summary dialect are not allowed. +- **Consistency impact**: Operation labels, related-record labels, provider explanation wording, redaction messaging, and audit-reference semantics must stay aligned with existing shared helpers so support workflows do not develop a local vocabulary. +- **Review focus**: Reviewers must verify that the bundle reuses shared link and explanation paths, does not duplicate record truth, and never embeds raw provider payloads or bundle-local provider-specific semantics by default. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: yes, for deep-link and explanation reuse only +- **Shared OperationRun UX contract/layer reused**: `OperationRunLinks` for tenant-safe operation routing and existing governance-run summary builders for run explanation language +- **Delegated start/completion UX behaviors**: tenant-safe `Open operation` and related-record link semantics are reused; queued toast, browser event, dedupe-or-blocked messaging, artifact-link creation beyond existing links, and queued DB notifications are `N/A` in this slice +- **Local surface-owned behavior that remains**: a read-only `Open support diagnostics` action from the tenant dashboard or operation detail surface +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: yes +- **Boundary classification**: mixed +- **Seams affected**: provider connection descriptors, provider reason translation, translated provider error excerpts, and redaction boundaries around provider-owned diagnostics +- **Neutral platform terms preserved or introduced**: support diagnostic bundle, tenant context, operation context, provider connection, related record, support summary, redaction reason, audit reference +- **Provider-specific semantics retained and why**: Microsoft-specific permission, consent, or provider failure reasons may appear only as translated excerpts inside provider-owned sub-sections when they are genuinely needed to explain the current issue. +- **Why this does not deepen provider coupling accidentally**: The bundle contract remains provider-neutral and references provider specifics only through existing provider-owned descriptors and reason translators. It does not introduce provider-shaped primary fields as platform-core truth. +- **Follow-up path**: none in this slice; later support or AI features can build on the same neutral bundle contract + +## 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 dashboard support diagnostic action | yes | Native Filament + shared primitives | header actions, diagnostic summaries, related links | page, action, preview | yes | Existing tenant dashboard action-surface exemption remains; this slice adds one bounded support action rather than reclassifying the dashboard as a queue | +| Monitoring operation detail support diagnostic action | yes | Native Filament + shared diagnostics primitives | operation detail, related links, audit drill-through | detail, action, preview | no | Extends the existing diagnostic surface instead of creating a second operation-detail dialect | + +## 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 dashboard support diagnostic action | Secondary Context Surface | An operator decides they need help or escalation for one tenant and wants a support-safe case summary before leaving the tenant workflow | Tenant identity, bundle freshness, provider connection state, latest relevant run or finding pressure, and a visible redaction boundary | Redacted section detail plus canonical links to runs, findings, reviews, reports, and audit events | Not primary because support is follow-up to tenant work, not the tenant’s daily decision queue | Follows tenant troubleshooting and escalation flow | Removes manual search across operations, provider, findings, reviews, and audit pages | +| Monitoring operation detail support diagnostic action | Tertiary Evidence / Diagnostics Surface | An operator is already inspecting one run and needs a support-safe summary they can act on or escalate | Run outcome, dominant issue, related record references, freshness, and a visible redaction boundary | Redacted section detail plus canonical links to provider, findings, review artifacts, and audit history | Not primary because operation detail is already a drill-in evidence surface | Follows monitoring drill-in workflow | Removes cross-page reconstruction from a single failed or degraded run | + +## 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 dashboard support diagnostic action | Dashboard / Overview / Actions | Tenant troubleshooting support entry point | Open the redacted tenant diagnostic bundle | Explicit header action opens a read-only support-diagnostic preview | forbidden | Existing tenant links remain secondary; bundle links live inside the preview | none | /admin/t/{tenant} | /admin/t/{tenant} | Active workspace, active tenant, bundle freshness, redaction notice | Support diagnostics / Support diagnostic bundle | Current issue summary, top related records, and redaction boundary | dashboard_exception - tenant dashboard already has a documented action-surface exemption; this action remains bounded and read-only | +| Monitoring operation detail support diagnostic action | Record / Detail / Actions | Canonical diagnostic detail support entry point | Open the redacted run diagnostic bundle | Existing operation detail page plus one explicit support-diagnostic action | forbidden | Existing related links remain secondary and continue to use the canonical operation surface | none | /admin/operations | /admin/operations/{run} | Workspace context, active tenant context when present, operation identifier, redaction notice | Support diagnostics / Support diagnostic bundle | Dominant issue, related records, freshness, and redaction boundary | 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 dashboard support diagnostic action | Workspace manager or support-capable tenant operator | Decide whether the tenant case can be escalated or troubleshot with one support-safe bundle | Dashboard action + read-only preview | Do I have enough support-safe context for this tenant without opening raw provider diagnostics? | Tenant identity, workspace context, provider connection state, latest relevant run summary, active finding pressure, latest report or review references, and redaction notice | Full related records remain on their native pages; raw payloads and unrestricted logs stay excluded | connection health, run freshness, finding pressure, report freshness, review availability | None | Open support diagnostics, open canonical related record links | none | +| Monitoring operation detail support diagnostic action | Workspace manager or support-capable operator | Decide whether one operation case has enough support-safe context for follow-up or escalation | Detail action + read-only preview | What is the current operation issue, what related records matter, and what is safe to share or reuse for support? | Humanized run summary, related provider and tenant references, current finding or review references, audit references, and redaction notice | Full diagnostics remain on canonical run, finding, provider, review, or audit pages; raw payloads stay excluded | execution outcome, provider connection state, artifact freshness, related finding pressure | None | Open support diagnostics, open canonical related record links | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: yes +- **New enum/state/reason family?**: no +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Support work starts too late because someone must first gather the relevant tenant, provider, run, finding, report, review, and audit context by hand. +- **Existing structure is insufficient because**: Existing pages explain local truth, but no current path assembles one support-safe, deterministic bundle across tenant and OperationRun context while enforcing redaction and deny-as-not-found behavior first. +- **Narrowest correct implementation**: Add one derived bundle contract for tenant and OperationRun contexts only, with references to existing canonical records, deterministic ordering, redaction, and audit logging. Do not persist the bundle, build a helpdesk product, or add a generic export framework. +- **Ownership cost**: Maintain one derived bundle schema, one deterministic redaction policy, one audit event family for bundle usage, and focused unit and feature coverage for authorization and reference continuity. +- **Alternative intentionally rejected**: A persisted `SupportDiagnosticPack` entity and broad raw-export pipeline were rejected as too heavy, and page-local copy/export helpers were rejected as too narrow and drift-prone. +- **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**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: Unit coverage proves deterministic section ordering, redaction behavior, and canonical reference shaping. Feature coverage proves tenant and OperationRun entry points, deny-as-not-found isolation, capability enforcement, and canonical related-link continuity without introducing browser or heavy-governance breadth. +- **New or expanded test families**: A focused support-diagnostics unit family plus targeted feature coverage for tenant-context and operation-context actions and authorization boundaries +- **Fixture / helper cost impact**: Moderate. Tests can reuse existing workspace, tenant, `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack`, and `AuditLog` fixtures, but must add explicit redaction and inaccessible-reference cases. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: standard-native-filament + monitoring-state-page +- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, with explicit assertions for redaction markers, stable ordering, 404 versus 403 semantics, canonical related-link reuse, and the absence of new run-creating or provider-backed side effects when diagnostics are opened. +- **Reviewer handoff**: Reviewers must confirm that no raw provider payloads or secrets appear in the bundle, that unauthorized cross-workspace or cross-tenant access is treated as not found, that entitled-but-uncapable users receive authorization failure, that canonical links and labels stay aligned with existing support helpers, and that opening diagnostics creates no new `OperationRun` or provider-backed side effect. +- **Budget / baseline / trend impact**: Low-to-moderate increase in narrow unit and feature coverage only; no new browser or heavy-governance baseline is expected. +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Open a tenant support-safe bundle (Priority: P1) + +As a workspace manager or support-capable operator, I want one tenant-scoped diagnostic bundle so I can start support without manually gathering records across multiple pages. + +**Why this priority**: This is the first support workflow compression win. If tenant context still has to be rebuilt by hand, the feature has not reduced founder-led support work. + +**Independent Test**: Can be fully tested by opening a tenant-scoped support diagnostic bundle from the tenant dashboard with seeded provider, finding, report, review, and audit records and verifying that the bundle is redacted, deterministic, and linked to canonical records only. + +**Acceptance Scenarios**: + +1. **Given** an entitled operator opens support diagnostics for a tenant with an unhealthy provider connection, recent failed run, open findings, and a recent tenant review, **When** the bundle renders, **Then** it shows one redacted tenant summary with canonical references to the provider connection, most relevant operation run, related findings, latest stored reports, tenant review or review pack when present, and relevant audit references. +2. **Given** the current user is not a member of the workspace or is not entitled to the tenant, **When** they try to open the tenant bundle, **Then** the system responds as not found and does not reveal whether the tenant has provider issues, findings, or support history. +3. **Given** the current user is entitled to the tenant but lacks the support-diagnostics capability, **When** they try to open the tenant bundle, **Then** the system denies the action as an authorization failure without revealing additional diagnostic detail. + +--- + +### User Story 2 - Open a run-centered support-safe bundle (Priority: P1) + +As a support-capable operator already inspecting a run, I want a run-centered diagnostic bundle that uses the same operation explanation language as Monitoring so I can diagnose or escalate one case quickly. + +**Why this priority**: A large share of support work begins with one suspicious run. If run context still requires cross-page reconstruction, the first-response workflow remains too slow. + +**Independent Test**: Can be fully tested by opening an operation-scoped support diagnostic bundle from the canonical operation detail surface and verifying that the bundle reuses existing humanized run summaries, canonical related links, and redaction rules. + +**Acceptance Scenarios**: + +1. **Given** an entitled operator opens support diagnostics for a failed or degraded operation run, **When** the bundle renders, **Then** it reuses the existing humanized operation explanation, includes the related provider connection, related finding, related stored report, related tenant review or review pack when present, and relevant audit references, and keeps those references canonical rather than duplicating record truth. +2. **Given** the operation context contains sensitive provider response data or raw payload excerpts, **When** the bundle renders, **Then** those values are excluded by default and replaced with explicit redaction markers or translated high-level reasons. + +--- + +### User Story 3 - Rely on deterministic, redacted support summaries (Priority: P2) + +As a product owner preparing later AI-assisted support, I want support diagnostic bundles to be deterministic and machine-readable so later support tooling can reuse them without widening access or depending on ad-hoc summaries. + +**Why this priority**: Deterministic structure is what makes the first slice reusable later without adding a second translation layer or unsafe copy-and-paste support flow. + +**Independent Test**: Can be fully tested by generating the same authorized bundle repeatedly against unchanged source truth and verifying stable section order, stable reference order, and stable redaction behavior. + +**Acceptance Scenarios**: + +1. **Given** the same authorized tenant or run input and unchanged source truth, **When** the bundle is generated multiple times, **Then** the same sections, reference order, and redaction outcomes appear in the same order every time. +2. **Given** a related record becomes missing or inaccessible after the first generation, **When** the bundle is generated again, **Then** the summary marks that record as missing or inaccessible without leaking details from the inaccessible record. + +### Edge Cases + +- A tenant may have no provider connection, no recent operation run, or no current tenant review; the bundle must still render a truthful support summary with explicit `missing`, `not yet observed`, or `not available` states. +- A run may reference a stored report that has no dedicated viewer surface. The bundle must reference the canonical record identity and freshness without inventing a new report page. +- A run may reference a review pack or tenant review that has expired, been deleted, or is no longer accessible; the bundle must degrade gracefully and preserve deny-as-not-found boundaries. +- Provider error detail may exist only in raw payload context. The bundle must prefer translated high-level reason semantics and a redaction marker instead of echoing raw payload content. +- Workspace-level audit events may exist without a tenant-bound detail record. The bundle must include only audit references that are valid for the authorized scope and primary context. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new destructive workflow, and no new queued support-processing pipeline. Bundle generation is a read-only supportability action. If implementation writes no `OperationRun`, it must still write `AuditLog` entries for bundle generation and any explicit copy or share action using redacted metadata only. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces one bounded derived bundle contract because current tenant and operation pages still force manual reconstruction for support. No new persistence, status family, or support-desk domain entity is added. The design stays aligned with derive-before-persist and explicit-before-generic by composing existing records instead of creating a generalized support framework. + +**Constitution alignment (XCUT-001):** This feature is cross-cutting across operation links, explanation summaries, provider reason translation, evidence or review viewers, and audit drill-through. It must extend the existing shared paths instead of creating page-local support-link or support-summary dialects. + +**Constitution alignment (PROV-001):** The support diagnostic bundle contract stays provider-neutral. Provider-specific content remains contextual inside provider-owned translated sections and must not become new platform-core fields in the bundle. + +**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit and feature coverage. No heavy-governance or browser lane is justified for the first slice. Fixture cost must remain explicit and limited to real support contexts using existing models. + +**Constitution alignment (OPS-UX):** Existing `OperationRun` lifecycle rules, `summary_counts`, and notification rules remain unchanged. The feature reuses `OperationRun` summaries and links only; it does not add a new operation type or change run-state ownership. + +**Constitution alignment (OPS-UX-START-001):** The feature reuses `OperationRunLinks` for tenant-safe URL resolution and existing operation labels. It does not add queued toasts, dedupe messaging, lifecycle notifications, or run-enqueued browser events. + +**Constitution alignment (RBAC-UX):** The affected authorization plane is the tenant-admin `/admin` plane, including tenant-context routes and the canonical tenantless operation detail viewer. Non-members or non-entitled users must receive 404. The new `support_diagnostics.view` gate is tenant-role scoped through the canonical tenant capability registry and role map, and the run-context action is only in scope when the referenced run resolves to an entitled tenant. Entitled members lacking that capability must receive 403. Server-side authorization must run before any bundle section resolves a tenant-owned record. Linked canonical destinations continue to enforce their own authorization rules. + +**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. + +**Constitution alignment (BADGE-001):** No new badge family is required. Existing status and reason semantics remain authoritative. + +**Constitution alignment (UI-FIL-001):** The feature must use native Filament page or header actions and read-only summary sections or infolist-style presentation. Local replacement markup for status semantics or redaction language is intentionally avoided. + +**Constitution alignment (UI-NAMING-001):** The target object is the support diagnostic bundle. Primary operator-facing verbs remain `Open support diagnostics`, `Open operation`, `Open finding`, `Open provider connection`, and related canonical record labels. Implementation-first terms such as payload blob, Graph response, or raw JSON must stay out of primary labels. + +**Constitution alignment (DECIDE-001):** The affected surfaces remain secondary or tertiary support and diagnostics contexts. The feature must not create a new decision queue or a new support-desk surface. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** Each affected surface keeps exactly one primary inspect or open model. The support-diagnostic action is read-only, secondary to the existing tenant or operation surface, and must not compete with mutation or add redundant `View` actions. + +**Constitution alignment (ACTSURF-001 - action hierarchy):** The support-diagnostic action is a contextual read-only action. It must stay separated from mutation and dangerous actions and must not turn into a mixed catch-all support menu. + +**Constitution alignment (OPSURF-001):** Default-visible content must stay operator-first: summary, freshness, related record references, and redaction state first; raw diagnostics remain on the original canonical pages. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from one existing page is insufficient because support context spans multiple canonical records. The derived bundle replaces manual reconstruction, not existing truth. Tests must prove business outcomes: safe first-response context, safe redaction, and safe authorization boundaries. + +**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. Each affected surface adds one explicit read-only support-diagnostic action, keeps one primary inspect model, adds no redundant `View` action, and adds no destructive placement changes. The tenant dashboard keeps its existing exemption for broader dashboard retrofit work. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** Any preview or detail presentation for the bundle must use summary-first sections, explicit redaction messaging, and one clear path back to the canonical records. The feature does not add create or edit screens. + +### Functional Requirements + +- **FR-241-001 Context coverage**: The system MUST allow an entitled operator to generate a support diagnostic bundle for at least two first-slice contexts: one tenant context and one specific `OperationRun` context. +- **FR-241-002 Canonical truth reuse**: The bundle MUST reference existing canonical records for workspace, tenant, `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack` when present, and `AuditLog` references, instead of duplicating their full truth into a new persisted support record. +- **FR-241-003 Tenant summary shape**: A tenant-context bundle MUST include a deterministic redacted summary covering tenant identity, workspace scope, provider connection health, most relevant recent operation context, relevant active findings, latest stored-report freshness, tenant-review or review-pack references when present, and audit references relevant to the same scope. +- **FR-241-004 Operation summary shape**: An `OperationRun`-context bundle MUST reuse existing humanized operation summary language, include related provider and artifact references when present, include relevant findings or review artifacts when present, and include audit references tied to the same run or immediate follow-up. +- **FR-241-005 Deterministic ordering**: For the same authorized input and unchanged source truth, the bundle MUST emit the same section order, same reference order, and same redaction outcomes. +- **FR-241-006 Redaction first**: Sensitive raw provider payloads, secrets, tokens, full response bodies, and unrestricted log excerpts MUST be excluded by default. When exclusion happens, the bundle MUST show explicit redaction markers or translated high-level reasons instead of silent omission. +- **FR-241-007 Access checks first**: Workspace membership, tenant entitlement, and support-diagnostics capability checks MUST run before any tenant-owned bundle section is resolved. +- **FR-241-008 404 versus 403 boundaries**: Non-members and non-entitled users MUST receive deny-as-not-found behavior. Entitled users lacking the support-diagnostics capability MUST receive authorization failure. Inaccessible related records inside an otherwise allowed bundle MUST degrade safely without revealing protected record details. +- **FR-241-009 Canonical link continuity**: Bundle links and record labels MUST reuse existing canonical navigation and explanation helpers so the bundle uses the same record names and destination meaning as the rest of the product. +- **FR-241-010 Freshness and completeness cues**: The bundle MUST show freshness, missing-data, or stale-data cues for included references so support workflows can distinguish fresh context from incomplete or absent context. +- **FR-241-011 Auditability**: Bundle generation and any explicit bundle copy or share action in scope MUST write audit entries that record actor, scope, primary context, and redaction mode without storing excluded raw payload content. +- **FR-241-012 No new support-pack persistence**: The first slice MUST NOT create a persisted `SupportDiagnosticPack` entity, a support queue, or a broad export pipeline. +- **FR-241-013 Later AI readiness**: The bundle contract MUST remain machine-readable and support-safe so later AI-assisted support can consume the same contract without requiring broader access. +- **FR-241-014 Graceful missing-reference handling**: If a referenced canonical record is missing, expired, or inaccessible, the bundle MUST identify that state explicitly and continue rendering the remaining authorized context. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Tenant dashboard support diagnostics | `App\Filament\Pages\TenantDashboard` | `Open support diagnostics` (read-only, capability-gated) | n/a | none | none | none added | `Open support diagnostics` | n/a | yes | Existing tenant dashboard exemption remains; this slice adds one bounded support action only | +| Monitoring operation detail support diagnostics | `App\Filament\Pages\Operations\TenantlessOperationRunViewer` | `Open support diagnostics` (read-only, capability-gated) | Existing operation detail remains the primary inspect model | none | none | n/a | `Open support diagnostics` | n/a | yes | No new destructive action, no redundant `View` action, no action-group catch-all | + +### Key Entities *(include if feature involves data)* + +- **Support Diagnostic Bundle**: A derived, read-only support-safe envelope for one tenant context or one operation context. +- **Diagnostic Reference Set**: The typed set of canonical record references included in the bundle, such as tenant, operation, provider connection, finding, stored report, tenant review, review pack, and audit reference. +- **Redaction Marker**: An explicit indicator that a sensitive value or raw payload section was intentionally excluded and why. +- **Diagnostic Context Slice**: The bounded entry context for one bundle generation request, either tenant context or `OperationRun` context. + +## Assumptions & Dependencies + +- Spec 240 Self-Service Tenant Onboarding & Connection Readiness already exists on its own feature branch and remains the immediately preceding supportability milestone. +- Existing `OperationRun`, `Finding`, `ProviderConnection`, `StoredReport`, `TenantReview`, `ReviewPack`, `AuditLog`, and operator explanation foundations are mature enough to support a derived bundle without introducing new persistence. +- Existing operation explanation and related-link helpers remain the authoritative path for run wording and canonical navigation continuity. +- `StoredReport` currently acts as canonical evidence truth without a dedicated general-purpose report viewer; the first slice therefore references report identity and freshness instead of inventing a new report surface. +- Product Usage & Adoption Telemetry and Operational Controls & Feature Flags remain deferred because they either depend on onboarding and readiness behavior landing first or require new greenfield control and telemetry infrastructure that this slice intentionally avoids. + +## Risks + +- Over-including provider diagnostics would create unnecessary data-exposure risk and undermine tenant or workspace isolation. +- Under-including context would push support users back to manual reconstruction and fail the workflow-compression goal. +- A page-local implementation on only one surface could drift from shared operation or provider language and create inconsistent support summaries. +- Deterministic bundle ordering must not accidentally depend on incidental query order or uncontrolled related-record selection. + +## Open Questions + +- None for the first slice. Follow-up questions about external ticket attachment, persistent support requests, or AI-assisted support behavior are explicitly deferred. + +## Non-Goals + +- Add an external ticketing or helpdesk integration. +- Create a support-desk product or support queue inside TenantPilot. +- Allow unrestricted raw provider payload export or download. +- Build a broad log export pipeline. +- Add an AI chatbot, autonomous AI support behavior, or agentic triage flow. +- Introduce a persisted support-pack entity in the first slice. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-241-001**: In acceptance review, an entitled operator can open a tenant or operation support diagnostic bundle and identify the current issue, the authorized scope, and the next canonical records to inspect within 30 seconds without manually searching across more than one additional product surface. +- **SC-241-002**: In validation scenarios, 100% of covered tenant and operation bundle cases exclude sensitive raw provider payloads by default while still surfacing the relevant related-record references and freshness cues. +- **SC-241-003**: In validation scenarios, 100% of unauthorized cross-workspace or cross-tenant bundle attempts return not found, and 100% of entitled-but-uncapable attempts return authorization failure. +- **SC-241-004**: In deterministic regression coverage, repeated generation of the same unchanged authorized bundle produces the same section order, same reference order, and same redaction markers. diff --git a/specs/241-support-diagnostic-pack/tasks.md b/specs/241-support-diagnostic-pack/tasks.md new file mode 100644 index 00000000..d0882dfb --- /dev/null +++ b/specs/241-support-diagnostic-pack/tasks.md @@ -0,0 +1,194 @@ +--- + +description: "Task list for feature implementation" +--- + +# Tasks: Support Diagnostic Pack + +**Input**: Design documents from `/specs/241-support-diagnostic-pack/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md + +**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in the targeted `fast-feedback` and `confidence` unit + feature suites listed in `specs/241-support-diagnostic-pack/quickstart.md`. + +## Scope Lock + +- This task list covers only tenant-context and `OperationRun`-context support diagnostic bundles on the existing admin-plane tenant dashboard and tenantless operation detail viewer. +- Deferred by design: external ticketing, standalone support pages or resources, raw export or download flows, AI runtime behavior, heavy browser coverage, and system-panel support surfaces. + +## Test Governance Notes + +- Lane assignment: targeted unit + feature proof from `specs/241-support-diagnostic-pack/quickstart.md` is the narrowest sufficient validation for bundle composition, authorization, redaction, canonical-link reuse, and audit logging. +- No new browser or heavy-governance family should be introduced; keep any helper or fixture growth local to `SupportDiagnostics` tests and cheap by default. +- Surface profile: `standard-native-filament` relief applies to the tenant dashboard action, and the canonical operation detail action must preserve the `monitoring-state-page` contract already documented in the spec and plan. +- If implementation leaves a bounded shared-helper or provider-boundary hotspot, record that outcome in `specs/241-support-diagnostic-pack/plan.md` and `specs/241-support-diagnostic-pack/quickstart.md` before merge instead of widening this slice. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Pin the first-slice scope, existing surfaces, and shared helpers before runtime edits begin. + +- [X] T001 Review the first-slice bundle contract, scope lock, and validation commands in `specs/241-support-diagnostic-pack/spec.md`, `specs/241-support-diagnostic-pack/plan.md`, `specs/241-support-diagnostic-pack/research.md`, `specs/241-support-diagnostic-pack/data-model.md`, `specs/241-support-diagnostic-pack/contracts/support-diagnostics.openapi.yaml`, and `specs/241-support-diagnostic-pack/quickstart.md` +- [X] T002 [P] Verify the existing tenant dashboard, canonical tenantless operation viewer, and shared helper seams that this slice must reuse in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, `apps/platform/app/Support/Providers/ProviderReasonTranslator.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`, and `apps/platform/app/Support/RedactionIntegrity.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish the shared derived-bundle, capability, and audit seams required by both entry contexts without adding persistence or a standalone support surface. + +**Critical**: No user story work should start until this phase is complete. + +- [X] T003 Create the derived support-diagnostics bundle shell with explicit `forTenant(...)` and `forOperationRun(...)` entry points and one documented runtime shape in `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` +- [X] T004 [P] Register `support_diagnostics.view` in the canonical tenant capability registry and tenant role map without adding any system-plane support variant in `apps/platform/app/Support/Auth/Capabilities.php` and `apps/platform/app/Services/Auth/RoleCapabilityMap.php` +- [X] T005 [P] Add the shared audit action identifier and redacted bundle-open metadata path for both contexts in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` + +**Checkpoint**: Foundation ready - both entry contexts can build on one derived bundle contract, one capability gate, and one audit path. + +--- + +## Phase 3: User Story 1 - Open A Tenant Support-Safe Bundle (Priority: P1) 🎯 MVP + +**Goal**: An entitled operator can open one tenant-scoped support diagnostic bundle from the tenant dashboard and get deterministic, redacted, canonical context without manual cross-page reconstruction. + +**Independent Test**: Seed an entitled tenant with provider, run, finding, stored-report, review, and audit truth, open support diagnostics from the tenant dashboard, and confirm the preview stays redacted, canonical, and authorization-safe. + +### Tests for User Story 1 + +- [X] T006 [P] [US1] Add tenant-context feature coverage for the redacted overview, provider or run or finding or report or review or audit references, freshness cues, and `404` versus `403` behavior in `apps/platform/tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php` + +### Implementation for User Story 1 + +- [X] T007 [US1] Implement tenant-context bundle collection from canonical tenant, provider connection, recent operation, findings, stored reports, tenant review or review pack, and audit truth in `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` +- [X] T008 [US1] Add the read-only `Open support diagnostics` header action and preview rendering on the tenant dashboard with capability gating after membership and entitlement are established in `apps/platform/app/Filament/Pages/TenantDashboard.php` +- [X] T009 [US1] Keep tenant-context provider excerpts and redaction notes on shared provider-owned helpers instead of page-local wording in `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`, `apps/platform/app/Support/Providers/ProviderReasonTranslator.php`, and `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` + +**Checkpoint**: User Story 1 is independently functional when an entitled operator can open a tenant bundle and identify the current issue plus the next canonical records to inspect without seeing raw payload content. + +--- + +## Phase 4: User Story 2 - Open A Run-Centered Support-Safe Bundle (Priority: P1) + +**Goal**: An entitled operator already inspecting one run can open the same support-safe contract from the canonical operation detail surface and keep existing run language and navigation semantics. + +**Independent Test**: Seed a failed or degraded run with related provider, finding, report, review, and audit truth, open support diagnostics from the tenantless operation detail viewer, and confirm the preview reuses humanized run wording plus canonical links only. + +### Tests for User Story 2 + +- [X] T010 [P] [US2] Add operation-context feature coverage for reused humanized run summary, canonical related links, explicit redaction markers, and deny-as-not-found boundaries in `apps/platform/tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php` + +### Implementation for User Story 2 + +- [X] T011 [US2] Implement operation-context bundle collection by reusing the current run explanation builder and run-bound related-record truth in `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` and `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` +- [X] T012 [US2] Add the read-only `Open support diagnostics` header action to the canonical tenantless operation viewer and enforce `support_diagnostics.view` only after existing run access has resolved an entitled tenant scope in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and `apps/platform/app/Policies/OperationRunPolicy.php` +- [X] T013 [US2] Reuse canonical operation and related-record navigation instead of support-local URLs in `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` + +**Checkpoint**: User Story 2 is independently functional when the run detail surface can open the same support bundle contract without changing `OperationRun` lifecycle truth or introducing a second link vocabulary. + +--- + +## Phase 5: User Story 3 - Rely On Deterministic, Redacted Support Summaries (Priority: P2) + +**Goal**: The same authorized tenant or run input always produces the same ordered, machine-readable, redacted support bundle so later tooling can reuse it safely. + +**Independent Test**: Generate the same authorized tenant and run bundles repeatedly against unchanged truth and confirm stable section order, stable reference order, explicit missing or inaccessible placeholders, and redaction markers for sensitive provider detail. + +### Tests for User Story 3 + +- [X] T014 [P] [US3] Add unit coverage for stable section order, stable reference order, and graceful missing or inaccessible degradation in `apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php` +- [X] T015 [P] [US3] Add unit coverage for redaction markers, translated high-level provider reasons, and excluded raw payload fields in `apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php` +- [X] T016 [P] [US3] Add shared feature coverage for cross-surface authorization boundaries so non-members or non-entitled actors stay `404` and capability-denied members stay `403` in `apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php` +- [X] T017 [P] [US3] Add feature coverage for bundle-open audit entries with redacted metadata for both tenant and run contexts, and assert that opening diagnostics stays DB-only, performs no outbound HTTP, dispatches no provider-backed work, creates no new `OperationRun`, and emits no queued OperationRun-start side effect, in `apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php` + +### Implementation for User Story 3 + +- [X] T018 [US3] Finalize deterministic ordering, freshness and completeness cues, explicit missing or inaccessible placeholders, and redaction-marker output in `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` and `apps/platform/app/Support/RedactionIntegrity.php` +- [X] T019 [US3] Wire bundle-open audit recording from both read-only actions without storing excluded payload content in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` + +**Checkpoint**: User Story 3 is independently functional when bundle output is deterministic, redacted, machine-readable, and auditable across both approved contexts. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Align the final contract docs, format touched files, and run the narrow validation set for this slice. + +- [X] T020 [P] Update the support-diagnostics contract and manual validation notes to match the final tenant and run action behavior in `specs/241-support-diagnostic-pack/contracts/support-diagnostics.openapi.yaml` and `specs/241-support-diagnostic-pack/quickstart.md` +- [X] T021 [P] Run Laravel Pint on touched PHP files through Sail before merge using `apps/platform/vendor/bin/sail` +- [X] T022 Run the targeted unit and feature validation commands listed in `specs/241-support-diagnostic-pack/quickstart.md` after implementation completes, explicitly using those suites to prove the DB-only / no outbound HTTP / no queued OperationRun-start side-effect contract via `apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php`, `apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php`, `apps/platform/tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php`, `apps/platform/tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php`, `apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php`, and `apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php` +- [X] T023 Record the final guardrail close-out, lane result, and any bounded `document-in-feature` versus `follow-up-spec` note for shared-helper or provider-boundary adjustments in `specs/241-support-diagnostic-pack/plan.md` and `specs/241-support-diagnostic-pack/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Phase 1 (Setup) starts immediately. +- Phase 2 (Foundational) depends on Phase 1 and blocks all user stories. +- Phase 3 (US1) depends on Phase 2 and establishes the MVP tenant-context bundle. +- Phase 4 (US2) depends on Phase 2 and is safest after US1 in practice because both stories extend `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` and the same canonical navigation helpers. +- Phase 5 (US3) depends on US1 and US2 because it hardens deterministic output, shared authorization proof, and audit behavior across both approved contexts. +- Phase 6 (Polish) depends on every implemented story. + +### User Story Dependencies + +- US1 is the MVP and the first independently shippable increment. +- US2 remains independently testable, but it reuses the same builder and helper seams as US1, so merge order should favor US1 first. +- US3 depends on both P1 stories because deterministic ordering, redaction, authorization proof, and audit proof must cover the shared bundle contract across both contexts. + +### Within Each User Story + +- Write the listed Pest coverage first and ensure it fails before implementation. +- Complete shared builder or helper changes before the final page-action rendering pass when both are required. +- Re-run the narrowest affected unit or feature suite after each story checkpoint before moving to the next story. + +--- + +## Parallel Opportunities + +### Phase 1 + +- T001 and T002 can run in parallel if one person confirms the feature documents while another verifies the shared code seams. + +### Phase 2 + +- T004 and T005 can run in parallel after T003 defines the shared bundle shell. + +### User Story 1 + +- T006 can start before runtime edits. +- T008 and T009 can overlap once T007 establishes the tenant-context bundle payload. + +### User Story 2 + +- T010 can start before runtime edits. +- T012 and T013 can overlap once T011 establishes the run-context bundle payload. + +### User Story 3 + +- T014, T015, T016, and T017 can run in parallel. +- T018 and T019 should stay sequential because both finalize the shared builder and action audit flow. + +--- + +## Implementation Strategy + +### MVP First + +1. Complete Phase 1. +2. Complete Phase 2. +3. Complete Phase 3 (US1). +4. Re-run the targeted tenant-context suite and stop for review. + +### Incremental Delivery + +1. Deliver US1 to compress tenant-context support work first. +2. Add US2 to bring the same contract to the canonical run-detail surface. +3. Add US3 to harden deterministic output, redaction, authorization, and audit proof across both contexts. + +### Team Strategy + +1. Finish Phase 2 together before splitting work. +2. Parallelize test authoring inside each story. +3. Sequence merges carefully around `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`, because all three stories extend the same shared bundle contract. -- 2.45.2 From d96abc65fb177d495d843db7a42d1e3b5b5a62c0 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 26 Apr 2026 15:43:47 +0000 Subject: [PATCH 16/36] Remove Findings lifecycle backfill operational surface (controls slice) (#280) Removes the Findings lifecycle backfill from the Operational Controls UI and OperationalControlCatalog. This patch is a safe, controls-only change; runbooks, jobs and other runtime artifacts are NOT removed yet. Follow-up work will delete the runbook service/scope, jobs, commands, and update tests. Files changed: - apps/platform/app/Filament/System/Pages/Ops/Controls.php - apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php - apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php - apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php - apps/platform/tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/280 --- .github/skills/spec-kit-end-to-end/SKILL.md | 939 ++++++++++++++++++ .../spec-kit-next-best-one-shot/SKILL.md | 398 -------- .../skills/spec-kit-one-shot-prep/SKILL.md | 294 ------ .../TenantpilotBackfillFindingLifecycle.php | 9 + .../Commands/TenantpilotRunDeployRunbooks.php | 5 + .../FindingResource/Pages/ListFindings.php | 118 ++- .../Filament/Resources/RestoreRunResource.php | 125 ++- .../Filament/System/Pages/Ops/Controls.php | 660 ++++++++++++ .../Filament/System/Pages/Ops/Runbooks.php | 23 +- .../Models/OperationalControlActivation.php | 73 ++ .../Services/Audit/WorkspaceAuditLogger.php | 17 +- ...indingsLifecycleBackfillRunbookService.php | 182 +++- .../app/Support/Audit/AuditActionId.php | 12 + .../app/Support/Auth/PlatformCapabilities.php | 2 + .../OperationalControlBlockedException.php | 31 + .../OperationalControlCatalog.php | 56 ++ .../OperationalControlDecision.php | 81 ++ .../OperationalControlEvaluator.php | 63 ++ apps/platform/config/tenantpilot.php | 3 - .../OperationalControlActivationFactory.php | 55 + ..._operational_control_activations_table.php | 38 + .../database/seeders/PlatformUserSeeder.php | 1 + .../system/pages/ops/controls.blade.php | 120 +++ .../ops/partials/controls-header.blade.php | 6 + .../operational-control-history.blade.php | 29 + .../AdminFindingsNoMaintenanceActionsTest.php | 5 +- ...ationalControlFindingsBackfillGateTest.php | 101 ++ .../NoAdHocOperationalControlBypassTest.php | 69 ++ ...ionalControlAuthorizationSemanticsTest.php | 133 +++ ...ationalControlRestoreExecutionGateTest.php | 261 +++++ .../OperationalControlManagementTest.php | 243 +++++ .../OperationalControlRunbookGateTest.php | 89 ++ .../OperationalControlCatalogTest.php | 26 + .../OperationalControlEvaluatorTest.php | 45 + .../OperationalControlScopeResolutionTest.php | 57 ++ .../checklists/requirements.md | 34 + .../operational-controls.contract.yaml | 153 +++ specs/242-operational-controls/data-model.md | 164 +++ specs/242-operational-controls/plan.md | 232 +++++ specs/242-operational-controls/quickstart.md | 50 + specs/242-operational-controls/research.md | 133 +++ specs/242-operational-controls/spec.md | 290 ++++++ specs/242-operational-controls/tasks.md | 187 ++++ 43 files changed, 4794 insertions(+), 818 deletions(-) create mode 100644 .github/skills/spec-kit-end-to-end/SKILL.md delete mode 100644 .github/skills/spec-kit-next-best-one-shot/SKILL.md delete mode 100644 .github/skills/spec-kit-one-shot-prep/SKILL.md create mode 100644 apps/platform/app/Filament/System/Pages/Ops/Controls.php create mode 100644 apps/platform/app/Models/OperationalControlActivation.php create mode 100644 apps/platform/app/Support/OperationalControls/OperationalControlBlockedException.php create mode 100644 apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php create mode 100644 apps/platform/app/Support/OperationalControls/OperationalControlDecision.php create mode 100644 apps/platform/app/Support/OperationalControls/OperationalControlEvaluator.php create mode 100644 apps/platform/database/factories/OperationalControlActivationFactory.php create mode 100644 apps/platform/database/migrations/2026_04_26_000000_create_operational_control_activations_table.php create mode 100644 apps/platform/resources/views/filament/system/pages/ops/controls.blade.php create mode 100644 apps/platform/resources/views/filament/system/pages/ops/partials/controls-header.blade.php create mode 100644 apps/platform/resources/views/filament/system/pages/ops/partials/operational-control-history.blade.php create mode 100644 apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php create mode 100644 apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php create mode 100644 apps/platform/tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php create mode 100644 apps/platform/tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php create mode 100644 apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php create mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php create mode 100644 apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php create mode 100644 apps/platform/tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php create mode 100644 apps/platform/tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php create mode 100644 specs/242-operational-controls/checklists/requirements.md create mode 100644 specs/242-operational-controls/contracts/operational-controls.contract.yaml create mode 100644 specs/242-operational-controls/data-model.md create mode 100644 specs/242-operational-controls/plan.md create mode 100644 specs/242-operational-controls/quickstart.md create mode 100644 specs/242-operational-controls/research.md create mode 100644 specs/242-operational-controls/spec.md create mode 100644 specs/242-operational-controls/tasks.md diff --git a/.github/skills/spec-kit-end-to-end/SKILL.md b/.github/skills/spec-kit-end-to-end/SKILL.md new file mode 100644 index 00000000..6a1c0ef9 --- /dev/null +++ b/.github/skills/spec-kit-end-to-end/SKILL.md @@ -0,0 +1,939 @@ +--- +name: spec-kit-end-to-end +description: End-to-end Spec Kit workflow for TenantPilot/TenantAtlas: select the next suitable spec candidate from roadmap/spec-candidates when needed, create or update spec.md/plan.md/tasks.md, optionally implement the active spec, run tests, browser smoke checks where applicable, post-implementation analysis, fix confirmed findings, and repeat until no in-scope findings remain or a stop condition is reached. +--- + +# Skill: Spec Kit End-to-End Workflow + +## Purpose + +Use this skill to run an end-to-end Spec Kit workflow for TenantPilot/TenantAtlas. + +This skill supports three modes: + +1. **Preparation only**: select or scope the next suitable feature from roadmap/spec-candidates and create or update `spec.md`, `plan.md`, and `tasks.md`. +2. **Implementation only**: implement an already prepared spec, run tests/checks, run strict post-implementation analysis, fix confirmed findings, and repeat until clean or a bounded stop condition is reached. +3. **End-to-end**: select or create a spec and then implement it in the same invocation, but only when the user explicitly requests end-to-end execution. + +The intended workflow is: + +```text +feature idea / roadmap item / spec candidate / active spec +→ determine requested mode +→ inspect repo truth, constitution, roadmap, spec candidates, existing specs, and relevant code +→ create or update spec.md + plan.md + tasks.md when preparation is needed +→ evaluate quality gates +→ implement only when the user explicitly asks for implementation or end-to-end execution +→ run relevant tests/checks +→ run browser smoke test when UI/user-facing flows are affected +→ run strict post-implementation analysis +→ fix confirmed in-scope findings +→ repeat test + analysis + fix loop until clean or bounded stop condition is reached +→ final report +``` + +## When to Use + +Use this skill when the user asks for any Spec Kit workflow around TenantPilot/TenantAtlas, including: + +- selecting the next best spec candidate from `docs/product/spec-candidates.md` and roadmap sources +- turning a feature idea, roadmap item, or candidate into `spec.md`, `plan.md`, and `tasks.md` +- preparing Spec Kit artifacts in one pass +- implementing an existing or newly prepared spec +- running implementation followed by strict analysis and fix iterations +- executing a full end-to-end flow from candidate selection to implementation verification + +Typical user prompts: + +```text +Nimm den nächsten sinnvollen Spec Candidate aus Roadmap/spec-candidates und mach spec, plan und tasks. +``` + +```text +Mach daraus spec, plan und tasks in einem Rutsch, aber noch nicht implementieren. +``` + +```text +Erstelle die Spec Kit Artefakte und implementiere sie danach mit Analyse/Fix-Loop. +``` + +```text +Implementiere die aktive Spec und analysiere danach, ob alles passt. +``` + +```text +Mach Spec Kit implement und danach analyse. Behebe alle Abweichungen und wiederhole bis sauber. +``` + +```text +Run end-to-end: choose next spec, create spec/plan/tasks, implement, analyze, fix until no in-scope findings remain. +``` + +## Hard Rules + +- Work strictly repo-based. +- Use the repository's actual Spec Kit workflow, scripts, templates, branch naming rules, and generated paths when available. +- Determine the requested mode before changing files: + - preparation only + - implementation only + - end-to-end preparation plus implementation +- Do not implement application code unless the user explicitly asks for implementation, `implement`, or end-to-end execution. +- When in preparation-only mode, create or update only Spec Kit preparation artifacts unless repository conventions require additional documentation artifacts. +- When in implementation mode, implement only the active or explicitly named Spec Kit feature. +- Do not manually invent spec numbers, branch names, or spec paths if Spec Kit provides a script or command for that. +- Do not bypass Spec Kit branch mechanics. +- Do not expand scope beyond the selected feature, `spec.md`, `plan.md`, and `tasks.md`. +- Do not silently add roadmap features, adjacent UX rewrites, speculative architecture, or unrelated refactors. +- Follow the repository constitution and existing Spec Kit conventions. +- Preserve TenantPilot/TenantAtlas terminology. +- Prefer small, reviewable, implementation-ready specs and patches over broad rewrites. +- Treat repository truth as authoritative over assumptions. +- If repository truth conflicts with the user-provided draft or spec, keep repository truth and document the deviation. +- If repository truth conflicts with implementation scope, stop and report the conflict unless there is an obvious minimal correction inside active spec scope. +- Fix only confirmed findings from tests, static checks, or post-implementation analysis. +- Fix all confirmed in-scope findings, regardless of severity, when they are safe and bounded. +- Do not leave Medium/Low findings open silently. If they are not fixed, document exactly why. +- Never hide failing tests, weaken assertions, delete meaningful coverage, or mark tasks complete without implementation evidence. +- Do not run destructive commands. +- Do not force checkout, reset, stash, rebase, merge, or delete branches. +- Do not perform database-destructive actions unless the repository test workflow explicitly requires isolated test database resets. +- Do not continue analysis/fix loops indefinitely. +- Do not move from preparation to implementation unless the Spec Readiness Gate passes or the user explicitly accepts the documented readiness risks. +- Do not move from implementation to final status unless the Test Gate, Browser Smoke Test Gate where applicable, and Post-Implementation Analysis Gate have been evaluated. +- Do not claim merge-readiness unless the Merge Readiness Gate passes. + +## Required Inputs + +The user should provide at least one of: + +- feature title and short goal +- full spec candidate +- roadmap item +- rough problem statement +- UX or architecture improvement idea +- explicit spec directory such as `specs/-/` +- instruction to use the current active Spec Kit feature +- instruction to choose the next best candidate from roadmap/spec-candidates + +If the input is incomplete, proceed with the smallest reasonable interpretation and document assumptions. + +If implementation is requested but the active spec cannot be determined safely, inspect the repository Spec Kit context first. If it is still ambiguous, stop and ask for the specific spec directory. + +## Required Repository Checks + +Always check the sources relevant to the requested mode. + +For preparation mode, always check: + +1. `.specify/memory/constitution.md` +2. `.specify/templates/` +3. `.specify/scripts/` +4. existing Spec Kit command usage or repository instructions, if present +5. current branch and git status +6. `specs/` +7. `docs/product/spec-candidates.md` +8. relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present +9. nearby existing specs with related terminology or scope +10. application code only as needed to avoid wrong naming, wrong architecture, duplicate concepts, impossible tasks, duplicated specs, or already-completed candidates + +For implementation mode, always check: + +1. active Spec Kit context / current branch +2. git status +3. `.specify/memory/constitution.md` +4. the active spec directory +5. `spec.md` +6. `plan.md` +7. `tasks.md` +8. relevant templates or conventions under `.specify/templates/` +9. nearby existing specs with related terminology or scope +10. application code surfaces referenced by the active spec +11. existing tests related to the changed behavior + +## Git and Branch Safety + +Before running any Spec Kit command or making implementation changes: + +1. Check the current branch. +2. Check whether the working tree is clean. +3. If there are unrelated uncommitted changes, stop and report them. Do not continue. +4. If the working tree only contains user-intended planning edits for this operation, continue cautiously. +5. Let Spec Kit create or switch to the correct feature branch when that is how the repository workflow works. +6. Do not force checkout, reset, stash, rebase, merge, or delete branches. +7. Do not overwrite existing specs. + +If the repo requires an explicit branch creation script for `specify`, use that script rather than manually creating the branch. + +## Mode Selection + +Select exactly one mode per invocation unless the user explicitly asks for end-to-end execution. + +### Preparation Only + +Use when the user asks to: + +- create spec/plan/tasks +- prepare a feature +- choose the next best spec candidate +- turn roadmap/spec-candidates into a spec +- run specify/plan/tasks/analyze without implementation +- avoid implementation + +Output is limited to Spec Kit preparation artifacts, preparation-artifact fixes, and final preparation summary. + +### Implementation Only + +Use when the user asks to: + +- implement an active spec +- run Spec Kit implement +- analyze after implementation +- fix implementation findings + +Requires an existing active or explicitly named spec. + +### End-to-End + +Use only when the user explicitly asks to: + +- choose/create the spec and then implement it +- run the full workflow +- go from candidate to implementation +- prepare and implement in one pass + +End-to-end mode must keep preparation and implementation phases clearly separated. + +End-to-end mode must pass the Candidate Selection Gate and Spec Readiness Gate before implementation begins. + +## Quality Gates + +Quality gates are mandatory checkpoints. They make the workflow safe for agentic execution without allowing uncontrolled scope expansion. + +### Gate 1: Candidate Selection Gate + +Required before creating a new spec from roadmap/spec-candidates. + +Pass criteria: + +- The selected candidate exists in roadmap/spec-candidate material or is directly provided by the user. +- The selected candidate is not already covered by an existing active or completed spec. +- The selected candidate aligns with current roadmap priorities or explicitly documented product direction. +- The candidate can be scoped as a small, reviewable, implementation-ready slice. +- Major adjacent concerns are listed as follow-up candidates instead of being hidden inside the primary scope. + +Fail behavior: + +- If no candidate satisfies the gate, stop and report the top candidates plus the reason none is ready. +- Do not invent a new roadmap direction to force progress. + +### Gate 2: Spec Readiness Gate + +Required before implementation starts, including end-to-end mode. + +Pass criteria: + +- `spec.md`, `plan.md`, and `tasks.md` exist. +- The spec has clear problem statement, user value, functional requirements, out-of-scope boundaries, acceptance criteria, assumptions, and risks. +- The plan identifies likely affected repo surfaces and does not contradict repository architecture. +- The tasks are small, ordered, verifiable, and include test/validation tasks. +- RBAC, workspace/tenant isolation, auditability, OperationRun semantics, evidence/result-truth, and UX requirements are addressed where relevant. +- No open question blocks safe implementation. +- The scope is small enough for a bounded implementation loop. + +Fail behavior: + +- In preparation-only mode, report the readiness gaps and provide the manual analysis prompt. +- In end-to-end mode, stop before implementation unless the user explicitly asked to proceed despite the documented readiness risks. +- Do not compensate for an unclear spec by inventing implementation scope. + +### Gate 3: Implementation Scope Gate + +Required before changing application code. + +Pass criteria: + +- The active spec directory is known. +- The implementation target is traceable to specific tasks in `tasks.md`. +- The affected files/surfaces are consistent with `plan.md` or clearly justified by repository truth. +- No required change would introduce unrelated product behavior. +- No required change conflicts with constitution, existing architecture, RBAC/isolation boundaries, or source-of-truth semantics. + +Fail behavior: + +- Stop before code changes and report the conflict or ambiguity. +- Suggest a minimal spec/plan/tasks correction if the issue is in the artifacts rather than the codebase. + +### Gate 4: Test Gate + +Required after implementation and after each fix iteration. + +Pass criteria: + +- Targeted tests for changed behavior pass. +- Relevant existing tests pass or failures are proven unrelated and documented. +- Static analysis, linting, formatting, or type checks used by the repository pass when applicable. +- Security/governance-relevant changes have backend, policy, or domain coverage; UI-only verification is not enough. +- Regression coverage exists for each fixed Blocker or High finding where practical. + +Fail behavior: + +- Fix in-scope failures before post-implementation analysis. +- If failures are unrelated or pre-existing, document evidence and continue only if they do not invalidate the active spec. +- Do not weaken tests to pass the gate. + +### Gate 5: Browser Smoke Test Gate + +Required before claiming implementation is ready for manual review/merge when the change affects Filament UI, Livewire interactions, navigation, forms, tables, actions, modals, dashboards, operation drilldowns, tenant/workspace context, or any user-facing flow. + +Not required for documentation-only, spec-only, backend-only, domain-only, enum-only, contract-only, or test-only changes unless those changes alter a user-facing flow. + +Pass criteria: + +- The relevant page or flow loads in a real browser or the repository's browser-testing harness. +- The primary action introduced or changed by the spec can be executed successfully. +- Expected UI states, labels, badges, actions, empty states, tables, forms, modals, and navigation are visible where relevant. +- Workspace/tenant context is preserved across the tested flow where relevant. +- RBAC/capability-dependent visibility behaves as expected where practical to verify. +- Livewire interactions complete without visible runtime errors. +- No relevant browser console errors occur. +- No failed network requests occur for the tested flow, except known unrelated development noise that is explicitly documented. +- OperationRun, audit, evidence, result, or support-diagnostic drilldowns work where relevant. +- The smoke-tested path is documented in the final response. + +Fail behavior: + +- Fix in-scope browser, UX, Livewire, navigation, or runtime failures before claiming merge-readiness. +- If a browser issue is unrelated existing debt, document evidence and residual risk. +- Do not treat a passing browser smoke test as a substitute for backend, policy, domain, security, feature, or integration tests. +- Do not expand the smoke test into a full E2E suite unless the user explicitly asks for that. + +### Gate 6: Post-Implementation Analysis Gate + +Required after implementation and after each fix iteration. + +Pass criteria: + +- The implementation has been checked against `spec.md`, `plan.md`, `tasks.md`, and constitution. +- All completed tasks have implementation evidence. +- No confirmed in-scope findings remain. +- Medium/Low findings are fixed when they are inside active spec scope, clearly bounded, and safe. +- Medium/Low findings that remain open are explicitly documented with one of these reasons: + - out of scope + - requires separate spec + - risky refactor + - existing unrelated debt + - not reproducible + - blocked by unclear product/architecture decision +- No scope expansion was introduced during fixes. + +Fail behavior: + +- Fix confirmed in-scope findings, regardless of severity, when the fix is safe and bounded. +- Stop instead of fixing when remediation would expand scope, contradict repo architecture, introduce risky refactors, or repeat the same failed fix twice. + +### Gate 7: Merge Readiness Gate + +Required before claiming the implementation is ready for manual review/merge. + +Pass criteria: + +- Spec Readiness Gate passed. +- Implementation Scope Gate passed. +- Test Gate passed. +- Browser Smoke Test Gate passed when applicable, or was explicitly marked not applicable with a reason. +- Post-Implementation Analysis Gate passed. +- `tasks.md` reflects actual completion status. +- No confirmed in-scope findings remain. +- All remaining findings are documented as out-of-scope, follow-up candidates, unrelated existing debt, or explicit residual risks. +- Final response includes changed files, tests/checks run, iterations performed, residual risks, and follow-up candidates. + +Fail behavior: + +- Do not claim merge-readiness. +- Report the failed gate, remaining risks, and the smallest recommended next action. + +## Candidate Selection Rules + +When the user asks for the next best spec from roadmap/spec-candidates: + +- Read `docs/product/spec-candidates.md`. +- Read relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present. +- Check existing specs to avoid duplicates. +- Prefer candidates that align with current roadmap priorities, platform foundations, enterprise UX, RBAC/isolation, auditability, observability, and governance workflow maturity. +- Prefer candidates that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers. +- Prefer small, implementation-ready slices over broad platform rewrites. +- If multiple candidates are plausible, choose one primary candidate and document why it was selected. +- Add non-selected relevant candidates as follow-up spec candidates, not hidden scope. +- Do not invent a candidate if existing roadmap/spec-candidate material provides a suitable one. +- Do not pick a spec only because it is listed first. +- Evaluate the Candidate Selection Gate before creating the spec directory. + +Evaluate candidates using these criteria: + +1. **Roadmap Fit**: Does it support the current roadmap sequence or unlock the next roadmap layer? +2. **Foundation Value**: Does it strengthen reusable platform foundations such as RBAC, isolation, auditability, evidence, OperationRun observability, provider boundaries, vocabulary, baseline/control/finding semantics, or enterprise UX patterns? +3. **Dependency Unblocking**: Does it make future specs smaller, safer, or more consistent? +4. **Scope Size**: Can it be implemented as a narrow, testable slice? +5. **Repo Readiness**: Does the repo already have enough structure to implement the next slice safely? +6. **Risk Reduction**: Does it reduce current architectural or product risk? +7. **User/Product Value**: Does it produce visible operator value or make the platform more sellable without heavy scope? + +## Required Selection Output Before Spec Kit Execution + +Before running the Spec Kit flow, identify: + +- selected candidate title +- source location in roadmap/spec-candidates +- why it was selected +- why close alternatives were deferred +- roadmap relationship +- smallest viable implementation slice +- proposed concise feature description to feed into `specify` + +The feature description must be product- and behavior-oriented. It should not be a low-level implementation plan. + +## Spec Kit Preparation Flow + +Use this section when the selected mode is preparation-only or end-to-end. + +### Step 1: Determine the repository's Spec Kit command pattern + +Inspect repository instructions and scripts to identify how this repo expects Spec Kit to be run. + +Common locations to inspect: + +```text +.specify/scripts/ +.specify/templates/ +.specify/memory/constitution.md +.github/prompts/ +.github/skills/ +README.md +specs/ +``` + +Use the repo-specific mechanism if present. + +### Step 2: Run `specify` + +Run the repository's `specify` flow using the selected candidate and the smallest viable slice. + +The `specify` input should include: + +- selected candidate title +- problem statement +- operator/user value +- roadmap relationship +- out-of-scope boundaries +- key acceptance criteria +- important enterprise constraints + +Let Spec Kit create the correct branch and spec location if that is the repo's configured behavior. + +### Step 3: Run `plan` + +Run the repository's `plan` flow for the generated spec. + +The `plan` input should keep the scope tight and should require repo-based alignment with: + +- constitution +- existing architecture +- workspace/tenant isolation +- RBAC +- OperationRun/observability where relevant +- evidence/snapshot/truth semantics where relevant +- Filament/Livewire conventions where relevant +- test strategy + +### Step 4: Run `tasks` + +Run the repository's `tasks` flow for the generated plan. + +The generated tasks must be: + +- ordered +- small +- testable +- grouped by phase +- limited to the selected scope +- suitable for later implementation or manual analysis before implementation + +### Step 5: Run preparation `analyze` + +Run the repository's `analyze` flow against the generated Spec Kit artifacts when the repository supports it. + +Analyze must check: + +- consistency between `spec.md`, `plan.md`, and `tasks.md` +- constitution alignment +- roadmap alignment +- whether the selected candidate was narrowed safely +- whether tasks are complete enough for implementation +- whether tasks accidentally require scope not described in the spec +- whether plan details conflict with repository architecture or terminology +- whether implementation risks are documented instead of silently ignored + +In preparation-only mode, do not use analyze as a trigger to implement application code. + +### Step 6: Fix preparation-artifact issues only + +If preparation analyze finds issues, fix only Spec Kit preparation artifacts such as: + +- `spec.md` +- `plan.md` +- `tasks.md` +- generated Spec Kit metadata files, if the repository uses them + +Allowed fixes include: + +- clarify requirements +- tighten scope +- move out-of-scope work into follow-up candidates +- correct terminology +- add missing tasks +- remove tasks not backed by the spec +- align plan language with repository architecture +- add missing acceptance criteria or validation tasks + +Forbidden fixes in preparation-only mode include: + +- modifying application code +- creating migrations +- editing models, services, jobs, policies, Filament resources, Livewire components, tests, or commands +- running implementation or test-fix loops +- changing runtime behavior + +### Step 7: Evaluate the Spec Readiness Gate + +After preparation analyze has passed or preparation-artifact issues have been fixed, evaluate the Spec Readiness Gate. + +In preparation-only mode, stop after this gate and do not implement. + +## Spec Directory Rules + +When creating a new spec directory, use the repository's Spec Kit-generated directory or path. + +If the repository does not provide a command for spec setup, use the next valid spec number and a kebab-case slug: + +```text +specs/-/ +``` + +The exact number must be derived from the current repository state and existing numbering conventions. + +Create or update preparation artifacts inside the selected spec directory: + +```text +specs/-/spec.md +specs/-/plan.md +specs/-/tasks.md +``` + +If the repository templates require additional preparation files, create them only when this is consistent with existing Spec Kit conventions. + +## `spec.md` Requirements + +The spec must be product- and behavior-oriented. It should avoid premature implementation detail unless needed for correctness. + +Include: + +- Feature title +- Problem statement +- Business/product value +- Primary users/operators +- User stories +- Functional requirements +- Non-functional requirements +- UX requirements +- RBAC/security requirements +- Auditability/observability requirements +- Data/truth-source requirements where relevant +- Out of scope +- Acceptance criteria +- Success criteria +- Risks +- Assumptions +- Open questions + +TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles: + +- workspace/tenant isolation +- capability-first RBAC +- auditability +- operation/result truth separation +- source-of-truth clarity +- calm enterprise operator UX +- progressive disclosure where useful +- no false positive calmness + +## `plan.md` Requirements + +The plan must be repo-aware and implementation-oriented, but it must not make code changes by itself. + +Include: + +- Technical approach +- Existing repository surfaces likely affected +- Domain/model implications +- UI/Filament implications +- Livewire implications where relevant +- OperationRun/monitoring implications where relevant +- RBAC/policy implications +- Audit/logging/evidence implications where relevant +- Data/migration implications where relevant +- Test strategy +- Rollout considerations +- Risk controls +- Implementation phases + +The plan should clearly distinguish where relevant: + +- execution truth +- artifact truth +- backup/snapshot truth +- recovery/evidence truth +- operator next action + +## `tasks.md` Requirements + +Tasks must be ordered, small, and verifiable. + +Include: + +- checkbox tasks +- phase grouping +- tests before or alongside implementation tasks where practical +- final validation tasks +- documentation/update tasks if needed +- explicit non-goals where useful + +Avoid vague tasks such as: + +```text +Clean up code +Refactor UI +Improve performance +Make it enterprise-ready +``` + +Prefer concrete tasks such as: + +```text +- [ ] Add a feature test covering workspace isolation for . +- [ ] Update to display . +- [ ] Add policy coverage for . +``` + +If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths. + +## Preparation Scope Control + +If the requested feature implies multiple independent concerns, create one primary spec for the smallest valuable slice and add a `Follow-up spec candidates` section. + +Examples of follow-up candidates: + +- assigned findings +- pending approvals +- personal work queue +- notification delivery settings +- evidence pack export hardening +- operation monitoring refinements +- autonomous governance decision surfaces + +Do not force all follow-up candidates into the primary spec. + +## Implementation Loop + +Only execute this section when the selected mode is implementation-only or end-to-end. + +Execute the loop in bounded phases: + +1. Evaluate the Spec Readiness Gate. +2. Evaluate the Implementation Scope Gate before changing application code. +3. Implement the active Spec Kit feature scope. +4. Run targeted tests and relevant static/dynamic checks. +5. Evaluate the Test Gate. +6. Run a Browser Smoke Test when the change affects UI/user-facing flows. +7. Evaluate the Browser Smoke Test Gate as passed, failed, or not applicable with a reason. +8. Run strict post-implementation analysis against spec, plan, tasks, constitution, changed code, changed tests, browser smoke results where applicable, and relevant existing patterns. +9. Evaluate the Post-Implementation Analysis Gate. +10. Identify confirmed findings by severity: Blocker, High, Medium, Low. +11. Fix all confirmed in-scope findings regardless of severity when safe and bounded. +12. Do not fix findings that require scope expansion, risky unrelated refactors, or architectural/product decisions outside the active spec; document them as follow-up/residual risks with reasons. +13. Re-run relevant tests and browser smoke checks where applicable after fixes. +14. Repeat test + browser smoke + analysis + fix loop until no confirmed in-scope findings remain or a stop condition is reached. +15. Evaluate the Merge Readiness Gate. +16. Report final implementation status, changed files, tests, browser smoke result, residual risks, failed/passed gates, and manual review prompt. + +## Stop Conditions + +Stop the implementation loop when any of the following is true: + +- No confirmed in-scope findings remain. +- The same finding appears twice after attempted fixes. +- A required fix conflicts with the spec, plan, constitution, or repository architecture. +- A required fix would expand scope beyond the active spec. +- A required fix would require a risky unrelated refactor. +- A required fix depends on an unresolved product or architecture decision. +- Tests reveal an unrelated pre-existing failure that cannot be safely fixed inside the active spec. +- Browser smoke testing reveals an unrelated pre-existing UI/runtime failure that cannot be safely fixed inside the active spec. +- Three analysis/fix iterations have already been completed. +- The repository state is ambiguous enough that continuing would risk damaging architecture or data semantics. + +When stopping before full cleanliness, report exactly why the loop stopped and what remains. + +## Post-Implementation Analysis Prompt + +Use this prompt internally after implementation and after each fix iteration: + +```markdown +Du bist ein Senior Staff Software Engineer, Software Architect und Enterprise SaaS Reviewer. + +Analysiere die Implementierung der aktiven Spec streng repo-basiert. + +Ziel: +Prüfe, ob die Umsetzung vollständig, konsistent, getestet und constitution-konform ist. + +Prüfe gegen: +- spec.md +- plan.md +- tasks.md +- .specify/memory/constitution.md +- geänderte Anwendungscodes +- geänderte Tests +- Browser-Smoke-Test-Ergebnis, falls UI/user-facing Flows betroffen sind +- bestehende Repository-Patterns + +Wichtig: +- Keine Spekulation ohne Repo-Beleg. +- Keine Scope-Erweiterung. +- Keine neuen Produktideen als Pflicht-Fixes. +- Findings nach Blocker, High, Medium, Low gruppieren. +- Für jedes Finding konkrete Datei-/Code-Belege nennen. +- Für jedes Finding eine minimale Remediation nennen. +- Separat ausweisen, welche Findings innerhalb der aktiven Spec behoben werden müssen. +- Medium/Low Findings innerhalb der aktiven Spec ebenfalls zur Behebung markieren, wenn sie sicher und bounded sind. +- Bei UI-/Filament-/Livewire-Änderungen prüfen, ob ein Browser Smoke Test durchgeführt wurde und ob der getestete Operator-Flow wirklich funktioniert. +- Findings, die nicht behoben werden sollen, nur als Follow-up/Residual Risk ausweisen, wenn sie out of scope, risky refactor, unrelated existing debt, not reproducible oder durch eine offene Produkt-/Architekturentscheidung blockiert sind. +- Wenn keine bestätigten In-Scope Findings verbleiben, klare Implementierungsfreigabe geben. +``` + +## Task Completion Rules + +- Keep `tasks.md` aligned with actual implementation status. +- Check off tasks only after the implementation and test evidence exists. +- If a task is obsolete because repository truth proves a different path, update the task note with the reason instead of silently deleting it. +- If a task cannot be completed inside scope, leave it unchecked and report why. + +## Testing Rules + +- Add or update tests for all changed business behavior. +- Include RBAC and workspace/tenant isolation tests where relevant. +- Include OperationRun, audit, evidence, or result-truth tests where relevant. +- Prefer regression tests for every fixed Blocker or High finding. +- Add regression tests for Medium/Low findings when the behavior is important and testable without excessive churn. +- Do not weaken tests to pass the suite. +- Do not treat a green UI path as sufficient without backend or policy coverage when the behavior is security- or governance-relevant. + +## Browser Smoke Test Rules + +Apply these rules when the active spec changes Filament UI, Livewire interactions, navigation, forms, tables, actions, modals, dashboards, operation drilldowns, tenant/workspace context, or any user-facing flow. + +The browser smoke test should be narrow and focused. It is not a full E2E suite unless explicitly requested. + +Minimum smoke path: + +1. Open the relevant page or entry point. +2. Confirm the expected workspace/tenant context where relevant. +3. Confirm the changed or newly introduced UI element is visible. +4. Execute the primary action or interaction changed by the spec. +5. Confirm the expected result state, notification, redirect, table update, modal state, operation link, or drilldown. +6. Check for relevant console errors. +7. Check for failed network requests related to the tested flow. +8. Document the tested path in the final response. + +For TenantPilot/TenantAtlas, pay special attention to: + +- Filament actions and header actions +- Livewire polling, modals, validation, and actions +- workspace/tenant context preservation +- RBAC/capability-dependent action visibility +- OperationRun links and drilldown continuity +- audit/evidence/result/support-diagnostic drilldowns where relevant +- empty states, badges, labels, and decision guidance where relevant + +Browser smoke testing is required for UI/user-facing changes and optional for backend-only changes. + +Do not treat browser smoke success as proof that backend security, policies, domain logic, auditability, or workspace/tenant isolation are correct. Those still require automated tests or repo-based verification. + +## Failure Handling + +If a Spec Kit command, preparation analyze phase, implementation step, test phase, browser smoke phase, or post-implementation analysis fails: + +1. Stop at the relevant gate or stop condition. +2. Report the failing command or phase. +3. Summarize the error. +4. Do not attempt unrelated implementation as a workaround. +5. Suggest the smallest safe next action. + +If the branch or working tree state is unsafe: + +1. Stop before running Spec Kit commands or implementation changes. +2. Report the current branch and relevant uncommitted files. +3. Ask the user to commit, stash, or move to a clean worktree. + +## Final Response Requirements + +For preparation-only mode, respond with: + +1. Selected candidate and why it was chosen +2. Why close alternatives were deferred +3. Current branch after Spec Kit execution, if changed +4. Generated spec path +5. Files created or updated by Spec Kit +6. Preparation analyze result summary +7. Preparation-artifact fixes applied after analyze +8. Assumptions made +9. Open questions, if any +10. Quality gates evaluated and their result +11. Recommended next implementation prompt +12. Explicit statement that no application implementation was performed + +For implementation-only or end-to-end mode, respond with: + +1. Active spec directory +2. Summary of implemented changes +3. Tests/checks run and their results +4. Browser smoke test result, tested path, or not-applicable reason +5. Quality gates passed/failed and number of analysis/fix iterations performed +6. Remaining in-scope findings, if any +7. Residual risks and follow-up candidates, if relevant +8. Files changed +9. Explicit statement whether the Merge Readiness Gate passed and whether the implementation is ready for manual review/merge + +Keep the final response concise, but include enough detail for the user to continue immediately. + +## Manual Review Prompts + +For preparation-only mode, provide a ready-to-copy prompt like this, adapted to the generated spec branch/path: + +```markdown +Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer. + +Analysiere die neu erstellte Spec `` streng repo-basiert. + +Ziel: +Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar und constitution-konform sind. + +Wichtig: +- Keine Implementierung. +- Keine Codeänderungen. +- Keine Scope-Erweiterung. +- Prüfe nur gegen Repo-Wahrheit. +- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs. +- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor. +- Wenn alles passt, gib eine klare Implementierungsfreigabe. +``` + +For preparation-only mode, also provide a ready-to-copy implementation prompt after analyze has passed or preparation-artifact issues have been fixed: + +```markdown +Du bist ein Senior Staff Software Engineer für TenantPilot/TenantAtlas. + +Implementiere die vorbereitete Spec `` streng anhand von `tasks.md`. + +Wichtig: +- Arbeite task-sequenziell. +- Ändere nur Dateien, die für die jeweilige Task notwendig sind. +- Halte dich an `spec.md`, `plan.md`, `tasks.md` und die Constitution. +- Keine Scope-Erweiterung. +- Keine Opportunistic Refactors. +- Führe passende Tests nach sinnvollen Task-Gruppen aus. +- Wenn eine Task unklar oder falsch ist, stoppe und dokumentiere den Konflikt statt frei zu improvisieren. +- Am Ende liefere geänderte Dateien, Teststatus, offene Risiken und nicht erledigte Tasks. +``` + +For implementation-only or end-to-end mode, provide a ready-to-copy prompt like this, adapted to the active spec number and slug: + +```markdown +Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer. + +Führe eine finale manuelle Review der implementierten Spec `-` streng repo-basiert durch. + +Ziel: +Prüfe, ob die Implementierung nach dem Agenten-Loop wirklich merge-ready ist. + +Wichtig: +- Keine Implementierung. +- Keine Codeänderungen. +- Keine Scope-Erweiterung. +- Prüfe gegen spec.md, plan.md, tasks.md und constitution.md. +- Prüfe die geänderten Dateien, Tests, Browser-Smoke-Test-Ergebnis, RBAC, Workspace-/Tenant-Isolation, Auditability, UX und OperationRun-Semantik, soweit relevant. +- Benenne nur konkrete Findings mit Repo-Beleg. +- Gib am Ende eine klare Entscheidung: Merge-ready, merge-ready with notes, oder not merge-ready. +``` + +## Example Invocations + +User: + +```text +Nutze den Skill spec-kit-end-to-end. +Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec. +Führe danach GitHub Spec Kit specify, plan, tasks und analyze in einem Rutsch aus. +Behebe alle analyze-Issues in den Spec-Kit-Artefakten. +Keine Application-Implementierung. +``` + +Expected behavior: + +1. Inspect constitution, Spec Kit scripts/templates, specs, roadmap, and spec candidates. +2. Check branch and working tree safety. +3. Compare candidate suitability. +4. Select the next best candidate. +5. Evaluate the Candidate Selection Gate. +6. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup. +7. Run the repository's real Spec Kit `plan` flow. +8. Run the repository's real Spec Kit `tasks` flow. +9. Run the repository's real Spec Kit preparation `analyze` flow. +10. Fix analyze issues only in Spec Kit preparation artifacts. +11. Evaluate the Spec Readiness Gate. +12. Stop before application implementation. +13. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, gates, and next implementation prompt. + +User: + +```text +Implementiere die aktive Spec. Danach analyse gegen spec/plan/tasks/constitution ausführen, alle in-scope Findings beheben und wiederhole bis sauber. +``` + +Expected behavior: + +1. Inspect active Spec Kit context, constitution, spec, plan, tasks, relevant code, and relevant tests. +2. Evaluate the Spec Readiness Gate and Implementation Scope Gate. +3. Implement only the active spec scope. +4. Run targeted tests and relevant checks. +5. Evaluate the Test Gate. +6. Run and evaluate Browser Smoke Test when UI/user-facing flows are affected. +7. Run post-implementation analysis. +8. Fix all confirmed in-scope findings regardless of severity when safe and bounded. +9. Repeat test + browser smoke + analysis + fix loop up to the stop conditions. +10. Evaluate the Merge Readiness Gate. +11. Report final status, changed files, tests, browser smoke result, residual risks, gates, and manual review prompt. + +User: + +```text +Run end-to-end: wähle die nächste sinnvolle Spec aus spec-candidates/roadmap, erstelle spec/plan/tasks, implementiere sie danach und wiederhole analyse/fix bis sauber. +``` + +Expected behavior: + +1. Run preparation mode first. +2. Clearly report the selected candidate and created spec directory. +3. Continue into implementation mode only because the user explicitly requested end-to-end execution. +4. Implement only the newly created active spec scope. +5. Run tests/checks, browser smoke checks where applicable, post-implementation analysis, and bounded fix iterations. +6. Fix all confirmed in-scope findings regardless of severity when safe and bounded. +7. Report final implementation status, gates, browser smoke result, and residual risks. +``` \ No newline at end of file diff --git a/.github/skills/spec-kit-next-best-one-shot/SKILL.md b/.github/skills/spec-kit-next-best-one-shot/SKILL.md deleted file mode 100644 index 59c91d9f..00000000 --- a/.github/skills/spec-kit-next-best-one-shot/SKILL.md +++ /dev/null @@ -1,398 +0,0 @@ ---- -name: spec-kit-next-best-one-shot -description: Select the most suitable next TenantPilot/TenantAtlas spec from roadmap and spec-candidates, then run the GitHub Spec Kit preparation flow in one pass: specify, plan, tasks, and analyze. Use when the user wants the agent to choose the next best spec, execute the real Spec Kit workflow including branch/spec-directory mechanics, analyze the generated artifacts, and fix preparation issues before implementation. This skill must not implement application code. ---- - -# Skill: Spec Kit Next-Best One-Shot Preparation - -## Purpose - -Use this skill when the user wants the agent to select the most suitable next spec from existing product planning sources and then execute the real GitHub Spec Kit preparation flow in one pass: - -1. select the next best spec candidate from roadmap and spec candidates -2. run the repository's Spec Kit `specify` flow for that selected candidate -3. run the repository's Spec Kit `plan` flow for the generated spec -4. run the repository's Spec Kit `tasks` flow for the generated plan -5. run the repository's Spec Kit `analyze` flow against the generated artifacts -6. fix issues in Spec Kit preparation artifacts only (`spec.md`, `plan.md`, `tasks.md`, and related Spec Kit metadata if required) -7. stop before implementation -8. provide a concise readiness summary for the user - -This skill must use the repository's actual Spec Kit scripts, commands, templates, branch naming rules, and generated paths. It must not manually bypass Spec Kit by creating arbitrary spec folders or files. The only allowed fixes after `analyze` are preparation-artifact fixes, not application-code implementation. - -The intended workflow is: - -```text -roadmap.md + spec-candidates.md -→ select next best spec -→ run Spec Kit specify -→ run Spec Kit plan -→ run Spec Kit tasks -→ run Spec Kit analyze -→ fix preparation-artifact issues -→ explicit implementation step later -``` - -## When to Use - -Use this skill when the user asks things like: - -```text -Nimm die nächste sinnvollste Spec aus roadmap und spec-candidates und führe specify, plan, tasks und analyze aus. -``` - -```text -Wähle die nächste geeignete Spec und mach den Spec-Kit-Flow inklusive analyze in einem Rutsch. -``` - -```text -Schau in roadmap.md und spec-candidates.md und starte daraus specify, plan, tasks und analyze. -``` - -```text -Such die beste nächste Spec aus und bereite sie per GitHub Spec Kit vollständig vor. -``` - -```text -Nimm angesichts Roadmap und Spec Candidates das sinnvollste nächste Thema, aber nicht implementieren. -``` - -## Hard Rules - -- Work strictly repo-based. -- Use the repository's actual GitHub Spec Kit workflow. -- Do not manually invent spec numbers, branch names, or spec paths if Spec Kit provides a script or command for that. -- Do not manually create `spec.md`, `plan.md`, or `tasks.md` when the Spec Kit workflow can generate them. -- Do not bypass Spec Kit branch mechanics. -- Run `analyze` after `tasks` when the repository supports it. -- Fix only issues found in Spec Kit preparation artifacts and planning metadata. -- Do not treat analyze findings as permission to implement product code. -- If analyze reports implementation work as missing, record it in `tasks.md` instead of implementing it. -- Do not implement application code. -- Do not modify production code. -- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task. -- Do not execute implementation commands. -- Do not run destructive commands. -- Do not invent roadmap priorities not supported by repository documents. -- Do not pick a spec only because it is listed first. -- Do not select broad platform rewrites when a smaller dependency-unlocking spec is more appropriate. -- Prefer specs that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers. -- Prefer small, reviewable, implementation-ready specs over large ambiguous themes. -- Preserve TenantPilot/TenantAtlas terminology. -- Follow the repository constitution and existing Spec Kit conventions. -- If repository truth conflicts with roadmap/candidate wording, keep repository truth and document the deviation. -- If no candidate is suitable, do not run Spec Kit commands and explain why. - -## Required Repository Checks Before Selection - -Before selecting the next spec, inspect: - -1. `.specify/memory/constitution.md` -2. `.specify/templates/` -3. `.specify/scripts/` -4. existing Spec Kit command usage or repository instructions, if present -5. `specs/` -6. `docs/product/spec-candidates.md` -7. roadmap documents under `docs/product/`, especially `roadmap.md` if present -8. nearby existing specs related to top candidate areas -9. current branch and git status -10. application code only as needed to verify whether a candidate is already done, blocked, duplicated, or technically mis-scoped - -Do not edit application code. - -## Git and Branch Safety - -Before running any Spec Kit command or script: - -1. Check the current branch. -2. Check whether the working tree is clean. -3. If there are unrelated uncommitted changes, stop and report them. Do not continue. -4. If the working tree only contains user-intended planning edits for this operation, continue cautiously. -5. Let Spec Kit create or switch to the correct feature branch when that is how the repository workflow works. -6. Do not force checkout, reset, stash, rebase, merge, or delete branches. -7. Do not overwrite existing specs. - -If the repo requires an explicit branch creation script for `specify`, use that script rather than manually creating the branch. - -## Candidate Selection Criteria - -Evaluate candidate specs using these criteria. - -### 1. Roadmap Fit - -Prefer candidates that directly support the current roadmap sequence or unlock the next roadmap layer. - -Examples: - -- governance foundations before advanced compliance views -- evidence/snapshot foundations before auditor packs -- control catalog foundations before CIS/NIS2 mappings -- decision/workflow surfaces before autonomous governance -- provider/platform boundary cleanup before multi-provider expansion - -### 2. Foundation Value - -Prefer candidates that strengthen reusable platform foundations: - -- RBAC and workspace/tenant isolation -- auditability -- evidence and snapshot truth -- operation observability -- provider boundary neutrality -- canonical vocabulary -- baseline/control/finding semantics -- enterprise detail-page or decision-surface patterns - -### 3. Dependency Unblocking - -Prefer specs that unblock multiple later candidates. - -A good next spec should usually make future specs smaller, safer, or more consistent. - -### 4. Scope Size - -Prefer a candidate that can be implemented as a narrow, testable slice. - -Avoid selecting: - -- broad platform rewrites -- vague product themes -- multi-feature bundles -- speculative future-provider frameworks -- large UX redesigns without a clear first slice - -### 5. Repo Readiness - -Prefer candidates where the repository already has enough structure to implement the next slice safely. - -Check whether related models, services, UI pages, tests, or concepts already exist. - -### 6. Risk Reduction - -Prefer candidates that reduce current architectural or product risk: - -- legacy dual-world semantics -- unclear truth ownership -- inconsistent operator UX -- missing audit/evidence boundaries -- repeated manual workflow friction -- false-positive calmness in governance surfaces - -### 7. User/Product Value - -Prefer specs that produce visible operator value or make the platform more sellable without creating heavy scope. - -## Required Selection Output Before Spec Kit Execution - -Before running the Spec Kit flow, identify: - -- selected candidate title -- source location in roadmap/spec-candidates -- why it was selected -- why close alternatives were deferred -- roadmap relationship -- smallest viable implementation slice -- proposed concise feature description to feed into `specify` - -The feature description must be product- and behavior-oriented. It should not be a low-level implementation plan. - -## Spec Kit Execution Flow - -After selecting the candidate, execute the real repository Spec Kit preparation sequence, including analysis and preparation-artifact fixes. - -### Step 1: Determine the repository's Spec Kit command pattern - -Inspect repository instructions and scripts to identify how this repo expects Spec Kit to be run. - -Common locations to inspect: - -```text -.specify/scripts/ -.specify/templates/ -.specify/memory/constitution.md -.github/prompts/ -.github/skills/ -README.md -specs/ -``` - -Use the repo-specific mechanism if present. - -### Step 2: Run `specify` - -Run the repository's `specify` flow using the selected candidate and the smallest viable slice. - -The `specify` input should include: - -- selected candidate title -- problem statement -- operator/user value -- roadmap relationship -- out-of-scope boundaries -- key acceptance criteria -- important enterprise constraints - -Let Spec Kit create the correct branch and spec location if that is the repo's configured behavior. - -### Step 3: Run `plan` - -Run the repository's `plan` flow for the generated spec. - -The `plan` input should keep the scope tight and should require repo-based alignment with: - -- constitution -- existing architecture -- workspace/tenant isolation -- RBAC -- OperationRun/observability where relevant -- evidence/snapshot/truth semantics where relevant -- Filament/Livewire conventions where relevant -- test strategy - -### Step 4: Run `tasks` - -Run the repository's `tasks` flow for the generated plan. - -The generated tasks must be: - -- ordered -- small -- testable -- grouped by phase -- limited to the selected scope -- suitable for later manual analysis before implementation - -### Step 5: Run `analyze` - -Run the repository's `analyze` flow against the generated Spec Kit artifacts. - -Analyze must check: - -- consistency between `spec.md`, `plan.md`, and `tasks.md` -- constitution alignment -- roadmap alignment -- whether the selected candidate was narrowed safely -- whether tasks are complete enough for implementation -- whether tasks accidentally require scope not described in the spec -- whether plan details conflict with repository architecture or terminology -- whether implementation risks are documented instead of silently ignored - -Do not use analyze as a trigger to implement application code. - -### Step 6: Fix preparation-artifact issues only - -If analyze finds issues, fix only Spec Kit preparation artifacts such as: - -- `spec.md` -- `plan.md` -- `tasks.md` -- generated Spec Kit metadata files, if the repository uses them - -Allowed fixes include: - -- clarify requirements -- tighten scope -- move out-of-scope work into follow-up candidates -- correct terminology -- add missing tasks -- remove tasks not backed by the spec -- align plan language with repository architecture -- add missing acceptance criteria or validation tasks - -Forbidden fixes include: - -- modifying application code -- creating migrations -- editing models, services, jobs, policies, Filament resources, Livewire components, tests, or commands -- running implementation or test-fix loops -- changing runtime behavior - -### Step 7: Stop - -After `analyze` has passed or preparation-artifact issues have been fixed, stop. - -Do not implement. -Do not modify application code. -Do not run implementation tests unless the repository's Spec Kit preparation command requires a non-destructive validation. - -## Failure Handling - -If a Spec Kit command or analyze phase fails: - -1. Stop immediately. -2. Report the failing command or phase. -3. Summarize the error. -4. Do not attempt implementation as a workaround. -5. Suggest the smallest safe next action. - -If the branch or working tree state is unsafe: - -1. Stop before running Spec Kit commands. -2. Report the current branch and relevant uncommitted files. -3. Ask the user to commit, stash, or move to a clean worktree. - -## Final Response Requirements - -After the Spec Kit preparation flow completes, respond with: - -1. Selected candidate -2. Why this candidate was selected -3. Why close alternatives were deferred -4. Current branch after Spec Kit execution -5. Generated spec path -6. Files created or updated by Spec Kit -7. Analyze result summary -8. Preparation-artifact fixes applied after analyze -9. Assumptions made -10. Open questions, if any -11. Recommended next implementation prompt -12. Explicit statement that no application implementation was performed - -Keep the response concise, but include enough detail for the user to continue immediately. - -## Required Next Implementation Prompt - -Always provide a ready-to-copy implementation prompt like this, adapted to the generated spec branch/path, but only after analyze has passed or preparation-artifact issues have been fixed: - -```markdown -Du bist ein Senior Staff Software Engineer für TenantPilot/TenantAtlas. - -Implementiere die vorbereitete Spec `` streng anhand von `tasks.md`. - -Wichtig: -- Arbeite task-sequenziell. -- Ändere nur Dateien, die für die jeweilige Task notwendig sind. -- Halte dich an `spec.md`, `plan.md`, `tasks.md` und die Constitution. -- Keine Scope-Erweiterung. -- Keine Opportunistic Refactors. -- Führe passende Tests nach sinnvollen Task-Gruppen aus. -- Wenn eine Task unklar oder falsch ist, stoppe und dokumentiere den Konflikt statt frei zu improvisieren. -- Am Ende liefere geänderte Dateien, Teststatus, offene Risiken und nicht erledigte Tasks. -``` - -## Example Invocation - -User: - -```text -Nutze den Skill spec-kit-next-best-one-shot. -Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec. -Führe danach GitHub Spec Kit specify, plan, tasks und analyze in einem Rutsch aus. -Behebe alle analyze-Issues in den Spec-Kit-Artefakten. -Keine Application-Implementierung. -``` - -Expected behavior: - -1. Inspect constitution, Spec Kit scripts/templates, specs, roadmap, and spec candidates. -2. Check branch and working tree safety. -3. Compare candidate suitability. -4. Select the next best candidate. -5. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup. -6. Run the repository's real Spec Kit `plan` flow. -7. Run the repository's real Spec Kit `tasks` flow. -8. Run the repository's real Spec Kit `analyze` flow. -9. Fix analyze issues only in Spec Kit preparation artifacts. -10. Stop before application implementation. -11. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, and next implementation prompt. -``` \ No newline at end of file diff --git a/.github/skills/spec-kit-one-shot-prep/SKILL.md b/.github/skills/spec-kit-one-shot-prep/SKILL.md deleted file mode 100644 index 4358e90e..00000000 --- a/.github/skills/spec-kit-one-shot-prep/SKILL.md +++ /dev/null @@ -1,294 +0,0 @@ ---- -name: spec-kit-one-shot-prep -description: Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks. ---- - - - -Define the functionality provided by this skill, including detailed instructions and examples ---- -name: spec-kit-one-shot-prep -description: Create Spec Kit preparation artifacts in one pass for TenantPilot/TenantAtlas features: spec.md, plan.md, and tasks.md. Use for feature ideas, roadmap items, spec candidates, governance/platform improvements, UX improvements, cleanup candidates, and repo-based preparation before manual analysis or implementation. This skill must not implement application code. ---- - -# Skill: Spec Kit One-Shot Preparation - -## Purpose - -Use this skill to create a complete Spec Kit preparation package for a new TenantPilot/TenantAtlas feature in one pass: - -1. `spec.md` -2. `plan.md` -3. `tasks.md` - -This skill prepares implementation work, but it must not perform implementation. - -The intended workflow is: - -```text -feature idea / roadmap item / spec candidate -→ one-shot spec + plan + tasks preparation -→ manual repo-based analysis/review -→ explicit implementation step later -``` - -## When to Use - -Use this skill when the user asks to create or prepare Spec Kit artifacts from: - -- a feature idea -- a spec candidate -- a roadmap item -- a product or UX requirement -- a governance/platform improvement -- an architecture cleanup candidate -- a refactoring preparation request -- a TenantPilot/TenantAtlas implementation idea that should first become a formal spec - -Typical user prompts: - -```text -Mach daraus spec, plan und tasks in einem Rutsch. -``` - -```text -Erstelle daraus eine neue Spec Kit Vorbereitung, aber noch nicht implementieren. -``` - -```text -Nimm diesen spec candidate und bereite spec/plan/tasks vor. -``` - -```text -Erzeuge die Spec Kit Artefakte, danach mache ich die Analyse manuell. -``` - -## Hard Rules - -- Work strictly repo-based. -- Do not implement application code. -- Do not modify production code. -- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task. -- Do not execute implementation commands. -- Do not run destructive commands. -- Do not expand scope beyond the provided feature idea. -- Do not invent architecture that conflicts with repository truth. -- Do not create broad platform rewrites when a smaller implementable spec is possible. -- Prefer small, reviewable, implementation-ready specs. -- Preserve TenantPilot/TenantAtlas terminology. -- Follow the repository constitution and existing Spec Kit conventions. -- If repository truth conflicts with the user-provided draft, keep repository truth and document the deviation. -- If the feature is too broad, split it into one primary spec and optional follow-up spec candidates. - -## Required Inputs - -The user should provide at least one of: - -- feature title and short goal -- full spec candidate -- roadmap item -- rough problem statement -- UX or architecture improvement idea - -If the input is incomplete, proceed with the smallest reasonable interpretation and document assumptions. Do not block on clarification unless the request is impossible to scope safely. - -## Required Repository Checks - -Before creating or updating Spec Kit artifacts, inspect the relevant repository sources. - -Always check: - -1. `.specify/memory/constitution.md` -2. `.specify/templates/` -3. `specs/` -4. `docs/product/spec-candidates.md` -5. relevant roadmap documents under `docs/product/` -6. nearby existing specs with related terminology or scope - -Check application code only as needed to avoid wrong naming, wrong architecture, or duplicate concepts. Do not edit application code. - -## Spec Directory Rules - -Create a new spec directory using the next valid spec number and a kebab-case slug: - -```text -specs/-/ -``` - -The exact number must be derived from the current repository state and existing numbering conventions. - -Create or update only these preparation artifacts inside the selected spec directory: - -```text -specs/-/spec.md -specs/-/plan.md -specs/-/tasks.md -``` - -If the repository templates require additional preparation files, create them only when this is consistent with existing Spec Kit conventions. Do not create implementation files. - -## `spec.md` Requirements - -The spec must be product- and behavior-oriented. It should avoid premature implementation detail unless needed for correctness. - -Include: - -- Feature title -- Problem statement -- Business/product value -- Primary users/operators -- User stories -- Functional requirements -- Non-functional requirements -- UX requirements -- RBAC/security requirements -- Auditability/observability requirements -- Data/truth-source requirements where relevant -- Out of scope -- Acceptance criteria -- Success criteria -- Risks -- Assumptions -- Open questions - -TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles: - -- workspace/tenant isolation -- capability-first RBAC -- auditability -- operation/result truth separation -- source-of-truth clarity -- calm enterprise operator UX -- progressive disclosure where useful -- no false positive calmness - -## `plan.md` Requirements - -The plan must be repo-aware and implementation-oriented, but still must not implement. - -Include: - -- Technical approach -- Existing repository surfaces likely affected -- Domain/model implications -- UI/Filament implications -- Livewire implications where relevant -- OperationRun/monitoring implications where relevant -- RBAC/policy implications -- Audit/logging/evidence implications where relevant -- Data/migration implications where relevant -- Test strategy -- Rollout considerations -- Risk controls -- Implementation phases - -The plan should clearly distinguish: - -- execution truth -- artifact truth -- backup/snapshot truth -- recovery/evidence truth -- operator next action - -Use those distinctions only where relevant to the feature. - -## `tasks.md` Requirements - -Tasks must be ordered, small, and verifiable. - -Include: - -- checkbox tasks -- phase grouping -- tests before or alongside implementation tasks where practical -- final validation tasks -- documentation/update tasks if needed -- explicit non-goals where useful - -Avoid vague tasks such as: - -```text -Clean up code -Refactor UI -Improve performance -Make it enterprise-ready -``` - -Prefer concrete tasks such as: - -```text -- [ ] Add a feature test covering workspace isolation for . -- [ ] Update to display . -- [ ] Add policy coverage for . -``` - -If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths. - -## Scope Control - -If the requested feature implies multiple independent concerns, create one primary spec for the smallest valuable slice and add a `Follow-up spec candidates` section. - -Examples of follow-up candidates: - -- assigned findings -- pending approvals -- personal work queue -- notification delivery settings -- evidence pack export hardening -- operation monitoring refinements -- autonomous governance decision surfaces - -Do not force all follow-up candidates into the primary spec. - -## Final Response Requirements - -After creating or updating the artifacts, respond with: - -1. Created or updated spec directory -2. Files created or updated -3. Important repo-based adjustments made -4. Assumptions made -5. Open questions, if any -6. Recommended next manual analysis prompt -7. Explicit statement that no implementation was performed - -Keep the final response concise, but include enough detail for the user to continue immediately. - -## Required Next Manual Analysis Prompt - -Always provide a ready-to-copy prompt like this, adapted to the created spec number and slug: - -```markdown -Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer. - -Analysiere die neu erstellte Spec `-` streng repo-basiert. - -Ziel: -Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar und constitution-konform sind. - -Wichtig: -- Keine Implementierung. -- Keine Codeänderungen. -- Keine Scope-Erweiterung. -- Prüfe nur gegen Repo-Wahrheit. -- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs. -- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor. -- Wenn alles passt, gib eine klare Implementierungsfreigabe. -``` - -## Example Invocation - -User: - -```text -Nimm diesen Spec Candidate und mach daraus spec, plan und tasks in einem Rutsch. Danach mache ich die Analyse manuell. -``` - -Expected behavior: - -1. Inspect constitution, templates, specs, roadmap, and candidate docs. -2. Determine the next valid spec number. -3. Create `spec.md`, `plan.md`, and `tasks.md` in the new spec directory. -4. Keep scope tight. -5. Do not implement. -6. Return the summary and next manual analysis prompt. \ No newline at end of file diff --git a/apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php b/apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php index 7769fb66..995cae4e 100644 --- a/apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php +++ b/apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php @@ -7,6 +7,7 @@ use App\Models\Tenant; use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService; use App\Services\Runbooks\FindingsLifecycleBackfillScope; +use App\Support\OperationalControls\OperationalControlBlockedException; use Illuminate\Console\Command; use Illuminate\Validation\ValidationException; @@ -51,6 +52,14 @@ public function handle(FindingsLifecycleBackfillRunbookService $runbookService): reason: null, source: 'cli', ); + } catch (OperationalControlBlockedException $e) { + $this->error(sprintf( + 'Backfill paused for tenant %d: %s', + (int) $tenant->getKey(), + $e->getMessage(), + )); + + return self::FAILURE; } catch (ValidationException $e) { $errors = $e->errors(); diff --git a/apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php b/apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php index fbc34280..1bbce8cb 100644 --- a/apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php +++ b/apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php @@ -7,6 +7,7 @@ use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService; use App\Services\Runbooks\FindingsLifecycleBackfillScope; use App\Services\Runbooks\RunbookReason; +use App\Support\OperationalControls\OperationalControlBlockedException; use Illuminate\Console\Command; use Illuminate\Validation\ValidationException; @@ -31,6 +32,10 @@ public function handle(FindingsLifecycleBackfillRunbookService $runbookService): $this->info('Deploy runbooks started (if needed).'); + return self::SUCCESS; + } catch (OperationalControlBlockedException $e) { + $this->info('Deploy runbooks paused: '.$e->getMessage()); + return self::SUCCESS; } catch (ValidationException $e) { $errors = $e->errors(); diff --git a/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php b/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php index ade68e16..d1b19989 100644 --- a/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php +++ b/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php @@ -6,17 +6,18 @@ use App\Filament\Resources\FindingResource; use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner; use App\Filament\Widgets\Tenant\FindingStatsOverview; -use App\Jobs\BackfillFindingLifecycleJob; use App\Models\Finding; use App\Models\Tenant; use App\Models\User; use App\Services\Findings\FindingWorkflowService; -use App\Services\OperationRunService; +use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService; +use App\Services\Runbooks\FindingsLifecycleBackfillScope; use App\Support\Auth\Capabilities; use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\OperationalControls\OperationalControlBlockedException; use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiTooltips; use Filament\Actions; @@ -107,83 +108,76 @@ protected function getHeaderActions(): array { $actions = []; - if ((bool) config('tenantpilot.allow_admin_maintenance_actions', false)) { - $actions[] = UiEnforcement::forAction( - Actions\Action::make('backfill_lifecycle') - ->label('Backfill findings lifecycle') - ->icon('heroicon-o-wrench-screwdriver') - ->color('gray') - ->requiresConfirmation() - ->modalHeading('Backfill findings lifecycle') - ->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.') - ->action(function (OperationRunService $operationRuns): void { - $user = auth()->user(); + $actions[] = UiEnforcement::forAction( + Actions\Action::make('backfill_lifecycle') + ->label('Backfill findings lifecycle') + ->icon('heroicon-o-wrench-screwdriver') + ->color('gray') + ->requiresConfirmation() + ->modalHeading('Backfill findings lifecycle') + ->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.') + ->action(function (FindingsLifecycleBackfillRunbookService $runbookService): void { + $user = auth()->user(); - if (! $user instanceof User) { - abort(403); - } + if (! $user instanceof User) { + abort(403); + } - $tenant = static::resolveTenantContextForCurrentPanel(); + $tenant = static::resolveTenantContextForCurrentPanel(); - if (! $tenant instanceof Tenant) { - abort(404); - } + if (! $tenant instanceof Tenant) { + abort(404); + } - $opRun = $operationRuns->ensureRunWithIdentity( - tenant: $tenant, - type: 'findings.lifecycle.backfill', - identityInputs: [ - 'tenant_id' => (int) $tenant->getKey(), - 'trigger' => 'backfill', - ], - context: [ - 'workspace_id' => (int) $tenant->workspace_id, - 'initiator_user_id' => (int) $user->getKey(), - ], + try { + $opRun = $runbookService->start( + scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), initiator: $user, + reason: null, + source: 'tenant_ui', ); + } catch (OperationalControlBlockedException $exception) { + Notification::make() + ->title($exception->title()) + ->body($exception->getMessage()) + ->warning() + ->send(); - $runUrl = OperationRunLinks::view($opRun, $tenant); + throw new \Filament\Support\Exceptions\Halt; + } - if ($opRun->wasRecentlyCreated === false) { - OpsUxBrowserEvents::dispatchRunEnqueued($this); - - OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('Open operation') - ->url($runUrl), - ]) - ->send(); - - return; - } - - $operationRuns->dispatchOrFail($opRun, function () use ($tenant, $user): void { - BackfillFindingLifecycleJob::dispatch( - tenantId: (int) $tenant->getKey(), - workspaceId: (int) $tenant->workspace_id, - initiatorUserId: (int) $user->getKey(), - ); - }); + $runUrl = OperationRunLinks::view($opRun, $tenant); + if ($opRun->wasRecentlyCreated === false) { OpsUxBrowserEvents::dispatchRunEnqueued($this); - OperationUxPresenter::queuedToast((string) $opRun->type) - ->body('The backfill will run in the background. You can continue working while it completes.') + OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) ->actions([ Actions\Action::make('view_run') ->label('Open operation') ->url($runUrl), ]) ->send(); - }) - ) - ->preserveVisibility() - ->requireCapability(Capabilities::TENANT_MANAGE) - ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) - ->apply(); - } + + return; + } + + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::queuedToast((string) $opRun->type) + ->body('The backfill will run in the background. You can continue working while it completes.') + ->actions([ + Actions\Action::make('view_run') + ->label('Open operation') + ->url($runUrl), + ]) + ->send(); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(); $actions[] = UiEnforcement::forAction( Actions\Action::make('triage_all_matching') diff --git a/apps/platform/app/Filament/Resources/RestoreRunResource.php b/apps/platform/app/Filament/Resources/RestoreRunResource.php index ad63d385..e1e8a781 100644 --- a/apps/platform/app/Filament/Resources/RestoreRunResource.php +++ b/apps/platform/app/Filament/Resources/RestoreRunResource.php @@ -18,7 +18,9 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use App\Models\Workspace; use App\Rules\SkipOrUuidRule; +use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Auth\CapabilityResolver; use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Intune\AuditLogger; @@ -31,14 +33,18 @@ use App\Services\Providers\ProviderOperationStartResult; use App\Support\Auth\Capabilities; use App\Support\BackupQuality\BackupQualityResolver; +use App\Support\Audit\AuditActionId; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterPresets; +use App\Support\OperationCatalog; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\ProviderOperationStartResultPresenter; +use App\Support\OperationalControls\OperationalControlBlockedException; +use App\Support\OperationalControls\OperationalControlEvaluator; use App\Support\Rbac\UiEnforcement; use App\Support\RestoreRunIdempotency; use App\Support\RestoreRunStatus; @@ -1921,16 +1927,26 @@ public static function createRestoreRun(array $data): RestoreRun ->executionSafetySnapshot($tenant, $user, $data) ->toArray(); - [$result, $restoreRun] = static::startQueuedRestoreExecution( - tenant: $tenant, - backupSet: $backupSet, - selectedItemIds: $selectedItemIds, - preview: $preview, - metadata: $metadata, - groupMapping: $groupMapping, - actorEmail: $actorEmail, - actorName: $actorName, - ); + try { + [$result, $restoreRun] = static::startQueuedRestoreExecution( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: $selectedItemIds, + preview: $preview, + metadata: $metadata, + groupMapping: $groupMapping, + actorEmail: $actorEmail, + actorName: $actorName, + ); + } catch (OperationalControlBlockedException $exception) { + Notification::make() + ->title($exception->title()) + ->body($exception->getMessage()) + ->warning() + ->send(); + + throw new \Filament\Support\Exceptions\Halt; + } app(ProviderOperationStartResultPresenter::class) ->notification( @@ -1978,6 +1994,13 @@ private static function startQueuedRestoreExecution( $initiator = auth()->user(); $initiator = $initiator instanceof User ? $initiator : null; + static::guardRestoreExecutionOperationalControl( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: $selectedItemIds, + initiator: $initiator, + ); + $queuedRestoreRun = null; $dispatcher = function (OperationRun $run) use ( @@ -2097,6 +2120,58 @@ private static function startQueuedRestoreExecution( return [$result, $queuedRestoreRun?->refresh()]; } + /** + * @param array|null $selectedItemIds + */ + private static function guardRestoreExecutionOperationalControl( + Tenant $tenant, + BackupSet $backupSet, + ?array $selectedItemIds, + ?User $initiator, + ): void { + $workspace = $tenant->workspace; + + if (! $workspace instanceof Workspace) { + throw new \RuntimeException('Restore execution requires a workspace context.'); + } + + $decision = app(OperationalControlEvaluator::class)->evaluate('restore.execute', $workspace); + + if (! $decision->isPaused()) { + return; + } + + app(WorkspaceAuditLogger::class)->log( + workspace: $workspace, + action: AuditActionId::OperationalControlExecutionBlocked, + context: [ + 'metadata' => array_filter([ + 'control_key' => $decision->controlKey, + 'scope_type' => $decision->matchedScopeType, + 'workspace_id' => (int) $workspace->getKey(), + 'reason_text' => $decision->reasonText, + 'expires_at' => $decision->expiresAt?->toIso8601String(), + 'actor_id' => $initiator?->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'selected_item_count' => is_array($selectedItemIds) ? count($selectedItemIds) : null, + 'requested_scope' => 'restore.execute', + ], static fn (mixed $value): bool => $value !== null && $value !== ''), + ], + actor: $initiator, + status: 'blocked', + resourceType: 'operational_control', + resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null, + targetLabel: OperationCatalog::label('restore.execute'), + summary: 'Restore execution blocked by operational control', + tenant: $tenant, + ); + + throw OperationalControlBlockedException::forDecision( + decision: $decision, + actionLabel: OperationCatalog::label('restore.execute'), + ); + } + /** * @param array|null $selectedItemIds */ @@ -2529,16 +2604,26 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction $metadata['rerun_of_restore_run_id'] = $record->id; - [$result, $newRun] = static::startQueuedRestoreExecution( - tenant: $tenant, - backupSet: $backupSet, - selectedItemIds: $selectedItemIds, - preview: $preview, - metadata: $metadata, - groupMapping: $groupMapping, - actorEmail: $actorEmail, - actorName: $actorName, - ); + try { + [$result, $newRun] = static::startQueuedRestoreExecution( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: $selectedItemIds, + preview: $preview, + metadata: $metadata, + groupMapping: $groupMapping, + actorEmail: $actorEmail, + actorName: $actorName, + ); + } catch (OperationalControlBlockedException $exception) { + Notification::make() + ->title($exception->title()) + ->body($exception->getMessage()) + ->warning() + ->send(); + + return; + } if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) { OpsUxBrowserEvents::dispatchRunEnqueued($livewire); diff --git a/apps/platform/app/Filament/System/Pages/Ops/Controls.php b/apps/platform/app/Filament/System/Pages/Ops/Controls.php new file mode 100644 index 00000000..a7c02a0e --- /dev/null +++ b/apps/platform/app/Filament/System/Pages/Ops/Controls.php @@ -0,0 +1,660 @@ +user(); + + if (! $user instanceof PlatformUser) { + return false; + } + + return $user->hasCapability(PlatformCapabilities::ACCESS_SYSTEM_PANEL) + && $user->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE); + } + + public function mount(): void + { + abort_unless(static::canAccess(), 403); + } + + public function getHeader(): ?View + { + return view('filament.system.pages.ops.partials.controls-header', [ + 'breadcrumbs' => filament()->hasBreadcrumbs() ? $this->getBreadcrumbs() : [], + 'heading' => $this->getHeading(), + 'subheading' => $this->getSubheading(), + ]); + } + + /** + * @return array + */ + protected function getHeaderActions(): array + { + return [ + $this->pauseRestoreExecuteAction(), + $this->resumeRestoreExecuteAction(), + $this->viewHistoryRestoreExecuteAction(), + ]; + } + + /** + * @return array> + */ + public function controlCards(): array + { + $catalog = app(OperationalControlCatalog::class); + + return array_map( + fn (string $controlKey): array => $this->controlSummary($controlKey), + $catalog->keys(), + ); + } + + /** + * @return array + */ + public function controlSummary(string $controlKey): array + { + $definition = app(OperationalControlCatalog::class)->definition($controlKey); + $activations = $this->activeActivationsForControl($controlKey); + + $effectiveState = $activations->isEmpty() ? 'enabled' : 'paused'; + $stateLabel = match (true) { + $activations->contains(fn (OperationalControlActivation $activation): bool => $activation->scope_type === 'global') => 'Paused globally', + $activations->isNotEmpty() => sprintf('Workspace pauses active (%d)', $activations->where('scope_type', 'workspace')->count()), + default => 'Enabled', + }; + + return [ + 'control_key' => $controlKey, + 'action_slug' => $this->actionSlug($controlKey), + 'label' => (string) $definition['label'], + 'effective_state' => $effectiveState, + 'state_label' => $stateLabel, + 'supported_scopes' => $definition['supported_scopes'], + 'affected_surfaces' => $definition['affected_surfaces'], + 'active_activations' => $activations + ->map(fn (OperationalControlActivation $activation): array => $this->activationSummary($activation)) + ->values() + ->all(), + 'history_count' => $this->recentAuditEventsForControl($controlKey)->count(), + ]; + } + + /** + * @return array{control_key: string, scope_type: string, workspace_id: ?int, workspace_count: int, tenant_count: int, summary: string} + */ + public function scopeImpactPreview(string $controlKey, string $scopeType, ?int $workspaceId): array + { + $label = app(OperationalControlCatalog::class)->label($controlKey); + + if ($scopeType === 'workspace') { + $workspace = is_int($workspaceId) + ? Workspace::query()->whereKey($workspaceId)->first() + : null; + + if (! $workspace instanceof Workspace) { + return [ + 'control_key' => $controlKey, + 'scope_type' => $scopeType, + 'workspace_id' => null, + 'workspace_count' => 0, + 'tenant_count' => 0, + 'summary' => 'Select a workspace to preview the scope impact.', + ]; + } + + $tenantCount = Tenant::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('external_id', '!=', 'platform') + ->count(); + + return [ + 'control_key' => $controlKey, + 'scope_type' => $scopeType, + 'workspace_id' => (int) $workspace->getKey(), + 'workspace_count' => 1, + 'tenant_count' => $tenantCount, + 'summary' => sprintf('%s will affect workspace %s and %d %s.', $label, $workspace->name, $tenantCount, $tenantCount === 1 ? 'tenant' : 'tenants'), + ]; + } + + $tenantCount = Tenant::query() + ->where('external_id', '!=', 'platform') + ->count(); + + $workspaceCount = Tenant::query() + ->where('external_id', '!=', 'platform') + ->distinct('workspace_id') + ->count('workspace_id'); + + return [ + 'control_key' => $controlKey, + 'scope_type' => 'global', + 'workspace_id' => null, + 'workspace_count' => $workspaceCount, + 'tenant_count' => $tenantCount, + 'summary' => sprintf('%s will affect %d %s across %d %s.', $label, $workspaceCount, $workspaceCount === 1 ? 'workspace' : 'workspaces', $tenantCount, $tenantCount === 1 ? 'tenant' : 'tenants'), + ]; + } + + public function pauseRestoreExecuteAction(): Action + { + return $this->pauseActionFor('restore.execute'); + } + + public function resumeRestoreExecuteAction(): Action + { + return $this->resumeActionFor('restore.execute'); + } + + public function viewHistoryRestoreExecuteAction(): Action + { + return $this->historyActionFor('restore.execute'); + } + + private function pauseActionFor(string $controlKey): Action + { + $label = app(OperationalControlCatalog::class)->label($controlKey); + + return Action::make('pause_'.$this->actionSlug($controlKey)) + ->label('Pause '.$label) + ->icon('heroicon-o-pause') + ->color('danger') + ->requiresConfirmation() + ->modalHeading('Pause '.$label) + ->modalDescription('Review the scope impact, reason, and optional expiry before confirming this control change.') + ->form($this->pauseFormSchema($controlKey)) + ->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void { + $actor = $this->controlsActor(); + [$scopeType, $workspace, $reasonText, $expiresAt] = $this->normalizePauseInput($data); + + $scopeQuery = $this->activationScopeQuery($controlKey, $scopeType, $workspace); + + (clone $scopeQuery) + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()) + ->delete(); + + $activation = (clone $scopeQuery)->notExpired()->first(); + $auditAction = $activation instanceof OperationalControlActivation + ? AuditActionId::OperationalControlUpdated + : AuditActionId::OperationalControlPaused; + + if ($activation instanceof OperationalControlActivation) { + $activation->fill([ + 'reason_text' => $reasonText, + 'expires_at' => $expiresAt, + 'updated_by_platform_user_id' => (int) $actor->getKey(), + ])->save(); + } else { + $activation = OperationalControlActivation::query()->create([ + 'control_key' => $controlKey, + 'scope_type' => $scopeType, + 'workspace_id' => $workspace instanceof Workspace ? (int) $workspace->getKey() : null, + 'reason_text' => $reasonText, + 'expires_at' => $expiresAt, + 'created_by_platform_user_id' => (int) $actor->getKey(), + ]); + } + + $this->recordControlMutation( + auditAction: $auditAction, + activation: $activation, + actor: $actor, + auditRecorder: $auditRecorder, + workspaceAuditLogger: $workspaceAuditLogger, + ); + + Notification::make() + ->title(sprintf('%s %s', $label, $auditAction === AuditActionId::OperationalControlPaused ? 'paused' : 'updated')) + ->success() + ->send(); + }); + } + + private function resumeActionFor(string $controlKey): Action + { + $label = app(OperationalControlCatalog::class)->label($controlKey); + + return Action::make('resume_'.$this->actionSlug($controlKey)) + ->label('Resume '.$label) + ->icon('heroicon-o-play') + ->color('gray') + ->requiresConfirmation() + ->modalHeading('Resume '.$label) + ->modalDescription('Remove the selected pause so new starts can proceed again.') + ->form($this->resumeFormSchema($controlKey)) + ->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void { + $actor = $this->controlsActor(); + [$scopeType, $workspace] = $this->normalizeResumeInput($data); + + $activation = $this->activationScopeQuery($controlKey, $scopeType, $workspace) + ->notExpired() + ->first(); + + if (! $activation instanceof OperationalControlActivation) { + Notification::make() + ->title(sprintf('%s already enabled', $label)) + ->warning() + ->send(); + + return; + } + + $activationSnapshot = $activation->replicate(); + $activationSnapshot->forceFill($activation->getAttributes()); + $activation->delete(); + + $this->recordControlMutation( + auditAction: AuditActionId::OperationalControlResumed, + activation: $activationSnapshot, + actor: $actor, + auditRecorder: $auditRecorder, + workspaceAuditLogger: $workspaceAuditLogger, + ); + + Notification::make() + ->title($label.' resumed') + ->success() + ->send(); + }); + } + + private function historyActionFor(string $controlKey): Action + { + $label = app(OperationalControlCatalog::class)->label($controlKey); + + return Action::make('view_history_'.$this->actionSlug($controlKey)) + ->label('View '.$label.' history') + ->link() + ->modalHeading($label.' history') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalContent(fn () => view('filament.system.pages.ops.partials.operational-control-history', [ + 'events' => $this->recentAuditEventsForControl($controlKey), + 'label' => $label, + ])); + } + + /** + * @return array + */ + private function pauseFormSchema(string $controlKey): array + { + return [ + Radio::make('scope_type') + ->label('Scope') + ->options([ + 'global' => 'Global', + 'workspace' => 'One workspace', + ]) + ->default('global') + ->live() + ->required(), + + Select::make('workspace_id') + ->label('Workspace') + ->searchable() + ->visible(fn (callable $get): bool => $get('scope_type') === 'workspace') + ->required(fn (callable $get): bool => $get('scope_type') === 'workspace') + ->live() + ->getSearchResultsUsing(function (string $search): array { + return Workspace::query() + ->where('name', 'like', "%{$search}%") + ->orderBy('name') + ->limit(25) + ->pluck('name', 'id') + ->all(); + }) + ->getOptionLabelUsing(function ($value): ?string { + if (! is_numeric($value)) { + return null; + } + + return Workspace::query()->whereKey((int) $value)->value('name'); + }), + + Textarea::make('reason_text') + ->label('Reason') + ->required() + ->minLength(5) + ->maxLength(500) + ->rows(4), + + DateTimePicker::make('expires_at') + ->label('Expires at') + ->seconds(false) + ->nullable(), + + Placeholder::make('scope_preview') + ->label('Scope impact preview') + ->content(function (callable $get) use ($controlKey): string { + $preview = $this->scopeImpactPreview( + $controlKey, + (string) ($get('scope_type') ?? 'global'), + is_numeric($get('workspace_id')) ? (int) $get('workspace_id') : null, + ); + + return (string) $preview['summary']; + }), + ]; + } + + /** + * @return array + */ + private function resumeFormSchema(string $controlKey): array + { + return [ + Radio::make('scope_type') + ->label('Scope') + ->options([ + 'global' => 'Global', + 'workspace' => 'One workspace', + ]) + ->default('global') + ->live() + ->required(), + + Select::make('workspace_id') + ->label('Workspace') + ->searchable() + ->visible(fn (callable $get): bool => $get('scope_type') === 'workspace') + ->required(fn (callable $get): bool => $get('scope_type') === 'workspace') + ->getSearchResultsUsing(function (string $search): array { + return Workspace::query() + ->where('name', 'like', "%{$search}%") + ->orderBy('name') + ->limit(25) + ->pluck('name', 'id') + ->all(); + }) + ->getOptionLabelUsing(function ($value): ?string { + if (! is_numeric($value)) { + return null; + } + + return Workspace::query()->whereKey((int) $value)->value('name'); + }), + + Placeholder::make('scope_preview') + ->label('Resume impact preview') + ->content(function (callable $get) use ($controlKey): string { + $preview = $this->scopeImpactPreview( + $controlKey, + (string) ($get('scope_type') ?? 'global'), + is_numeric($get('workspace_id')) ? (int) $get('workspace_id') : null, + ); + + return (string) $preview['summary']; + }), + ]; + } + + private function controlsActor(): PlatformUser + { + $actor = auth('platform')->user(); + + if (! $actor instanceof PlatformUser) { + abort(403); + } + + if (! $actor->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE)) { + abort(403); + } + + return $actor; + } + + /** + * @return array{0: string, 1: ?Workspace, 2: string, 3: ?CarbonInterface} + */ + private function normalizePauseInput(array $data): array + { + [$scopeType, $workspace] = $this->resolveScopeInput($data); + $reasonText = trim((string) ($data['reason_text'] ?? '')); + + if ($reasonText === '') { + throw ValidationException::withMessages([ + 'reason_text' => 'A reason is required.', + ]); + } + + $expiresAt = null; + + if (filled($data['expires_at'] ?? null)) { + $expiresAt = Carbon::parse((string) $data['expires_at']); + + if ($expiresAt->lessThanOrEqualTo(now())) { + throw ValidationException::withMessages([ + 'expires_at' => 'Expiry must be in the future.', + ]); + } + } + + return [$scopeType, $workspace, $reasonText, $expiresAt]; + } + + /** + * @return array{0: string, 1: ?Workspace} + */ + private function normalizeResumeInput(array $data): array + { + return $this->resolveScopeInput($data); + } + + /** + * @return array{0: string, 1: ?Workspace} + */ + private function resolveScopeInput(array $data): array + { + $scopeType = (string) ($data['scope_type'] ?? 'global'); + + if (! in_array($scopeType, ['global', 'workspace'], true)) { + throw ValidationException::withMessages([ + 'scope_type' => 'Invalid scope selected.', + ]); + } + + if ($scopeType === 'global') { + return [$scopeType, null]; + } + + $workspaceId = $data['workspace_id'] ?? null; + + if (! is_numeric($workspaceId)) { + throw ValidationException::withMessages([ + 'workspace_id' => 'A workspace is required for workspace scope.', + ]); + } + + $workspace = Workspace::query()->whereKey((int) $workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + throw ValidationException::withMessages([ + 'workspace_id' => 'The selected workspace could not be found.', + ]); + } + + return [$scopeType, $workspace]; + } + + private function activationScopeQuery(string $controlKey, string $scopeType, ?Workspace $workspace): \Illuminate\Database\Eloquent\Builder + { + $query = OperationalControlActivation::query() + ->forControl($controlKey) + ->where('scope_type', $scopeType); + + if ($scopeType === 'workspace') { + $query->where('workspace_id', (int) $workspace?->getKey()); + } else { + $query->whereNull('workspace_id'); + } + + return $query; + } + + private function recordControlMutation( + AuditActionId $auditAction, + OperationalControlActivation $activation, + PlatformUser $actor, + AuditRecorder $auditRecorder, + WorkspaceAuditLogger $workspaceAuditLogger, + ): void { + $label = app(OperationalControlCatalog::class)->label((string) $activation->control_key); + $summary = sprintf('%s %s', $label, match ($auditAction) { + AuditActionId::OperationalControlPaused => 'paused', + AuditActionId::OperationalControlUpdated => 'updated', + AuditActionId::OperationalControlResumed => 'resumed', + default => 'changed', + }); + + $metadata = array_filter([ + 'control_key' => (string) $activation->control_key, + 'scope_type' => (string) $activation->scope_type, + 'workspace_id' => is_numeric($activation->workspace_id) ? (int) $activation->workspace_id : null, + 'reason_text' => $activation->reason_text, + 'expires_at' => $activation->expires_at?->toIso8601String(), + 'actor_id' => (int) $actor->getKey(), + ], static fn (mixed $value): bool => $value !== null && $value !== ''); + + if ((string) $activation->scope_type === 'global') { + $auditRecorder->record( + action: $auditAction, + context: ['metadata' => $metadata], + actor: AuditActorSnapshot::platform($actor), + target: new AuditTargetSnapshot( + type: 'operational_control', + id: (string) $activation->getKey(), + label: $label, + ), + outcome: 'success', + summary: $summary, + ); + + return; + } + + $workspace = Workspace::query()->whereKey((int) $activation->workspace_id)->firstOrFail(); + + $workspaceAuditLogger->log( + workspace: $workspace, + action: $auditAction, + context: ['metadata' => $metadata], + actor: $actor, + status: 'success', + resourceType: 'operational_control', + resourceId: (string) $activation->getKey(), + targetLabel: $label, + summary: $summary, + ); + } + + /** + * @return Collection + */ + private function activeActivationsForControl(string $controlKey): Collection + { + return OperationalControlActivation::query() + ->forControl($controlKey) + ->notExpired() + ->with(['workspace', 'createdBy', 'updatedBy']) + ->orderByRaw("CASE WHEN scope_type = 'global' THEN 0 ELSE 1 END") + ->orderBy('workspace_id') + ->orderBy('id') + ->get(); + } + + /** + * @return array + */ + private function activationSummary(OperationalControlActivation $activation): array + { + $owner = $activation->updatedBy ?? $activation->createdBy; + $workspaceName = $activation->workspace?->name; + + return [ + 'id' => (int) $activation->getKey(), + 'scope_type' => (string) $activation->scope_type, + 'scope_label' => (string) $activation->scope_type === 'global' + ? 'Global' + : sprintf('Workspace: %s', $workspaceName ?? '#'.(int) $activation->workspace_id), + 'workspace_id' => is_numeric($activation->workspace_id) ? (int) $activation->workspace_id : null, + 'workspace_name' => $workspaceName, + 'reason_text' => (string) $activation->reason_text, + 'expires_at' => $activation->expires_at?->toIso8601String(), + 'expires_label' => $activation->expires_at?->diffForHumans() ?? 'No expiry', + 'owner_name' => $owner?->name ?: $owner?->email ?: 'Unknown operator', + ]; + } + + /** + * @return Collection + */ + private function recentAuditEventsForControl(string $controlKey): Collection + { + return AuditLog::query() + ->where('metadata->control_key', $controlKey) + ->whereIn('action', [ + AuditActionId::OperationalControlPaused->value, + AuditActionId::OperationalControlUpdated->value, + AuditActionId::OperationalControlResumed->value, + AuditActionId::OperationalControlExecutionBlocked->value, + ]) + ->latestFirst() + ->limit(10) + ->get(); + } + + private function actionSlug(string $controlKey): string + { + return str_replace('.', '_', $controlKey); + } +} \ No newline at end of file diff --git a/apps/platform/app/Filament/System/Pages/Ops/Runbooks.php b/apps/platform/app/Filament/System/Pages/Ops/Runbooks.php index 47a45401..89cc1c02 100644 --- a/apps/platform/app/Filament/System/Pages/Ops/Runbooks.php +++ b/apps/platform/app/Filament/System/Pages/Ops/Runbooks.php @@ -14,6 +14,7 @@ use App\Services\System\AllowedTenantUniverse; use App\Support\Auth\PlatformCapabilities; use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OperationalControls\OperationalControlBlockedException; use App\Support\System\SystemOperationRunLinks; use Filament\Actions\Action; use Filament\Forms\Components\Radio; @@ -168,12 +169,22 @@ protected function getHeaderActions(): array 'reason_text' => $data['reason_text'] ?? null, ]); - $run = $runbookService->start( - scope: $scope, - initiator: $user, - reason: $reason, - source: 'system_ui', - ); + try { + $run = $runbookService->start( + scope: $scope, + initiator: $user, + reason: $reason, + source: 'system_ui', + ); + } catch (OperationalControlBlockedException $exception) { + Notification::make() + ->title($exception->title()) + ->body($exception->getMessage()) + ->warning() + ->send(); + + throw new \Filament\Support\Exceptions\Halt; + } $viewUrl = SystemOperationRunLinks::view($run); diff --git a/apps/platform/app/Models/OperationalControlActivation.php b/apps/platform/app/Models/OperationalControlActivation.php new file mode 100644 index 00000000..1f217e33 --- /dev/null +++ b/apps/platform/app/Models/OperationalControlActivation.php @@ -0,0 +1,73 @@ + */ + use HasFactory; + + protected $guarded = []; + + protected $casts = [ + 'expires_at' => 'datetime', + ]; + + protected static function newFactory(): OperationalControlActivationFactory + { + return OperationalControlActivationFactory::new(); + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(PlatformUser::class, 'created_by_platform_user_id'); + } + + public function updatedBy(): BelongsTo + { + return $this->belongsTo(PlatformUser::class, 'updated_by_platform_user_id'); + } + + public function scopeForControl(Builder $query, string $controlKey): Builder + { + return $query->where('control_key', trim($controlKey)); + } + + public function scopeForGlobalScope(Builder $query): Builder + { + return $query->where('scope_type', 'global'); + } + + public function scopeForWorkspaceScope(Builder $query, int|Workspace $workspace): Builder + { + $workspaceId = $workspace instanceof Workspace + ? (int) $workspace->getKey() + : (int) $workspace; + + return $query + ->where('scope_type', 'workspace') + ->where('workspace_id', $workspaceId); + } + + public function scopeNotExpired(Builder $query): Builder + { + return $query->where(function (Builder $query): void { + $query + ->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + } +} \ No newline at end of file diff --git a/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php b/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php index 71bd480e..958ebb86 100644 --- a/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php +++ b/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php @@ -6,6 +6,7 @@ use App\Models\Tenant; use App\Models\OperationRun; +use App\Models\PlatformUser; use App\Models\User; use App\Models\Workspace; use App\Support\Audit\AuditActionId; @@ -24,7 +25,7 @@ public function log( Workspace $workspace, string|AuditActionId $action, array $context = [], - ?User $actor = null, + User|PlatformUser|null $actor = null, string $status = 'success', ?string $resourceType = null, ?string $resourceId = null, @@ -37,14 +38,16 @@ public function log( ?int $operationRunId = null, ?Tenant $tenant = null, ): \App\Models\AuditLog { - $resolvedActor = $actor instanceof User - ? AuditActorSnapshot::human($actor) - : AuditActorSnapshot::fromLegacy( + $resolvedActor = match (true) { + $actor instanceof User => AuditActorSnapshot::human($actor), + $actor instanceof PlatformUser => AuditActorSnapshot::platform($actor), + default => AuditActorSnapshot::fromLegacy( type: $actorType ?? AuditActorType::infer($action instanceof AuditActionId ? $action->value : $action, $actorId, $actorEmail, $actorName, $context), id: $actorId, email: $actorEmail, label: $actorName, - ); + ), + }; return $this->auditRecorder->record( action: $action, @@ -71,7 +74,7 @@ public function logTenantLifecycleAction( Tenant $tenant, string|AuditActionId $action, array $context = [], - ?User $actor = null, + User|PlatformUser|null $actor = null, string $status = 'success', ?string $summary = null, ): \App\Models\AuditLog { @@ -96,7 +99,7 @@ public function logSupportDiagnosticsOpened( Tenant $tenant, string $contextType, array $bundle, - ?User $actor = null, + User|PlatformUser|null $actor = null, ?OperationRun $operationRun = null, ): \App\Models\AuditLog { $sectionCount = is_array($bundle['sections'] ?? null) ? count($bundle['sections']) : 0; diff --git a/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php b/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php index 37e8f735..8302ff85 100644 --- a/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php +++ b/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php @@ -10,15 +10,24 @@ use App\Models\OperationRun; use App\Models\PlatformUser; use App\Models\Tenant; +use App\Models\User; use App\Models\Workspace; use App\Notifications\OperationRunCompleted; use App\Services\Alerts\AlertDispatchService; +use App\Services\Audit\AuditRecorder; +use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Auth\BreakGlassSession; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Services\System\AllowedTenantUniverse; +use App\Support\Audit\AuditActionId; +use App\Support\Audit\AuditActorSnapshot; +use App\Support\Audit\AuditTargetSnapshot; +use App\Support\OperationCatalog; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\OperationalControls\OperationalControlBlockedException; +use App\Support\OperationalControls\OperationalControlEvaluator; use App\Support\System\SystemOperationRunLinks; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -35,6 +44,9 @@ public function __construct( private readonly OperationRunService $operationRunService, private readonly AuditLogger $auditLogger, private readonly AlertDispatchService $alertDispatchService, + private readonly OperationalControlEvaluator $operationalControls, + private readonly AuditRecorder $auditRecorder, + private readonly WorkspaceAuditLogger $workspaceAuditLogger, ) {} /** @@ -48,6 +60,7 @@ public function preflight(FindingsLifecycleBackfillScope $scope): array action: 'platform.ops.runbooks.preflight', scope: $scope, operationRunId: null, + initiator: null, context: [ 'preflight' => $result, ], @@ -58,7 +71,7 @@ public function preflight(FindingsLifecycleBackfillScope $scope): array public function start( FindingsLifecycleBackfillScope $scope, - ?PlatformUser $initiator, + User|PlatformUser|null $initiator, ?RunbookReason $reason, string $source, ): OperationRun { @@ -88,13 +101,41 @@ public function start( ]); } - $platformTenant = $this->platformTenant(); - $workspace = $platformTenant->workspace; + $workspace = null; + $tenant = null; + + if ($scope->isSingleTenant()) { + $tenant = Tenant::query()->whereKey((int) $scope->tenantId)->firstOrFail(); + $this->allowedTenantUniverse->ensureAllowed($tenant); + + $workspace = $tenant->workspace; + } else { + $platformTenant = $this->platformTenant(); + $workspace = $platformTenant->workspace; + } if (! $workspace instanceof Workspace) { throw new \RuntimeException('Platform tenant is missing its workspace.'); } + $decision = $this->operationalControls->evaluate(self::RUNBOOK_KEY, $workspace); + + if ($decision->isPaused()) { + $this->auditBlockedStart( + decision: $decision, + scope: $scope, + workspace: $workspace, + tenant: $tenant, + initiator: $initiator, + source: $source, + ); + + throw OperationalControlBlockedException::forDecision( + decision: $decision, + actionLabel: OperationCatalog::label(self::RUNBOOK_KEY), + ); + } + if ($scope->isAllTenants()) { $lockKey = sprintf('tenantpilot:runbooks:%s:workspace:%d', self::RUNBOOK_KEY, (int) $workspace->getKey()); $lock = Cache::lock($lockKey, 900); @@ -120,7 +161,7 @@ public function start( } return $this->startSingleTenant( - tenantId: (int) $scope->tenantId, + tenant: $tenant, initiator: $initiator, reason: $reason, preflight: $preflight, @@ -327,7 +368,7 @@ private function countDriftDuplicateConsolidations(Tenant $tenant): int private function startAllTenants( Workspace $workspace, - ?PlatformUser $initiator, + User|PlatformUser|null $initiator, ?RunbookReason $reason, array $preflight, string $source, @@ -349,7 +390,7 @@ private function startAllTenants( source: $source, isBreakGlassActive: $isBreakGlassActive, ), - initiator: null, + initiator: $initiator instanceof User ? $initiator : null, ); if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) { @@ -361,6 +402,7 @@ private function startAllTenants( action: 'platform.ops.runbooks.start', scope: FindingsLifecycleBackfillScope::allTenants(), operationRunId: (int) $run->getKey(), + initiator: $initiator, context: [ 'preflight' => $preflight, 'is_break_glass' => $isBreakGlassActive, @@ -382,15 +424,16 @@ private function startAllTenants( } private function startSingleTenant( - int $tenantId, - ?PlatformUser $initiator, + ?Tenant $tenant, + User|PlatformUser|null $initiator, ?RunbookReason $reason, array $preflight, string $source, bool $isBreakGlassActive, ): OperationRun { - $tenant = Tenant::query()->whereKey($tenantId)->firstOrFail(); - $this->allowedTenantUniverse->ensureAllowed($tenant); + if (! $tenant instanceof Tenant) { + throw new \RuntimeException('Target tenant is required for single-tenant runs.'); + } $run = $this->operationRunService->ensureRunWithIdentity( tenant: $tenant, @@ -408,7 +451,7 @@ private function startSingleTenant( source: $source, isBreakGlassActive: $isBreakGlassActive, ), - initiator: null, + initiator: $initiator instanceof User ? $initiator : null, ); if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) { @@ -420,6 +463,7 @@ private function startSingleTenant( action: 'platform.ops.runbooks.start', scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), operationRunId: (int) $run->getKey(), + initiator: $initiator, context: [ 'preflight' => $preflight, 'is_break_glass' => $isBreakGlassActive, @@ -458,7 +502,7 @@ private function platformTenant(): Tenant private function buildRunContext( int $workspaceId, FindingsLifecycleBackfillScope $scope, - ?PlatformUser $initiator, + User|PlatformUser|null $initiator, ?RunbookReason $reason, array $preflight, string $source, @@ -490,6 +534,12 @@ private function buildRunContext( 'name' => (string) $initiator->name, 'is_break_glass' => $isBreakGlassActive, ]; + } elseif ($initiator instanceof User) { + $context['tenant_initiator'] = [ + 'user_id' => (int) $initiator->getKey(), + 'email' => (string) $initiator->email, + 'name' => (string) $initiator->name, + ]; } return $context; @@ -514,23 +564,10 @@ private function auditSafely( string $action, FindingsLifecycleBackfillScope $scope, ?int $operationRunId, + User|PlatformUser|null $initiator, array $context = [], ): void { try { - $platformTenant = $this->platformTenant(); - - $actor = auth('platform')->user(); - - $actorId = null; - $actorEmail = null; - $actorName = null; - - if ($actor instanceof PlatformUser) { - $actorId = (int) $actor->getKey(); - $actorEmail = (string) $actor->email; - $actorName = (string) $actor->name; - } - $metadata = [ 'runbook_key' => self::RUNBOOK_KEY, 'scope' => $scope->mode, @@ -540,6 +577,37 @@ private function auditSafely( 'user_agent' => request()->userAgent(), ]; + if ($initiator instanceof User && $scope->isSingleTenant()) { + $tenant = Tenant::query()->whereKey((int) $scope->tenantId)->first(); + + if ($tenant instanceof Tenant) { + $this->auditLogger->log( + tenant: $tenant, + action: $action, + context: [ + 'metadata' => array_filter($metadata, static fn (mixed $value): bool => $value !== null), + ] + $context, + actorId: (int) $initiator->getKey(), + actorEmail: (string) $initiator->email, + actorName: (string) $initiator->name, + status: 'success', + resourceType: 'operation_run', + resourceId: $operationRunId !== null ? (string) $operationRunId : null, + ); + + return; + } + } + + $platformTenant = $this->platformTenant(); + $platformActor = $initiator instanceof PlatformUser + ? $initiator + : auth('platform')->user(); + + $actorId = $platformActor instanceof PlatformUser ? (int) $platformActor->getKey() : null; + $actorEmail = $platformActor instanceof PlatformUser ? (string) $platformActor->email : null; + $actorName = $platformActor instanceof PlatformUser ? (string) $platformActor->name : null; + $this->auditLogger->log( tenant: $platformTenant, action: $action, @@ -558,6 +626,68 @@ private function auditSafely( } } + private function auditBlockedStart( + \App\Support\OperationalControls\OperationalControlDecision $decision, + FindingsLifecycleBackfillScope $scope, + Workspace $workspace, + ?Tenant $tenant, + User|PlatformUser|null $initiator, + string $source, + ): void { + try { + $metadata = array_filter([ + 'control_key' => $decision->controlKey, + 'scope_type' => $decision->matchedScopeType, + 'workspace_id' => (int) $workspace->getKey(), + 'reason_text' => $decision->reasonText, + 'expires_at' => $decision->expiresAt?->toIso8601String(), + 'actor_id' => $initiator instanceof User || $initiator instanceof PlatformUser ? (int) $initiator->getKey() : null, + 'requested_scope' => $scope->mode, + 'target_tenant_id' => $scope->tenantId, + 'source' => $source, + 'runbook_key' => self::RUNBOOK_KEY, + ], static fn (mixed $value): bool => $value !== null && $value !== ''); + + $summary = sprintf('%s blocked by operational control', OperationCatalog::label(self::RUNBOOK_KEY)); + + if ($scope->isAllTenants()) { + $this->auditRecorder->record( + action: AuditActionId::OperationalControlExecutionBlocked, + context: ['metadata' => $metadata], + actor: $initiator instanceof PlatformUser ? AuditActorSnapshot::platform($initiator) : null, + target: new AuditTargetSnapshot( + type: 'operational_control', + id: $decision->sourceActivationId, + label: OperationCatalog::label(self::RUNBOOK_KEY), + ), + outcome: 'blocked', + summary: $summary, + ); + + return; + } + + if (! $tenant instanceof Tenant) { + return; + } + + $this->workspaceAuditLogger->log( + workspace: $workspace, + action: AuditActionId::OperationalControlExecutionBlocked, + context: ['metadata' => $metadata], + actor: $initiator, + status: 'blocked', + resourceType: 'operational_control', + resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null, + targetLabel: OperationCatalog::label(self::RUNBOOK_KEY), + summary: $summary, + tenant: $tenant, + ); + } catch (Throwable) { + // Audit is fail-safe (must not crash runbooks). + } + } + private function notifyInitiatorSafely(OperationRun $run): void { try { diff --git a/apps/platform/app/Support/Audit/AuditActionId.php b/apps/platform/app/Support/Audit/AuditActionId.php index 094366f7..1e1f6140 100644 --- a/apps/platform/app/Support/Audit/AuditActionId.php +++ b/apps/platform/app/Support/Audit/AuditActionId.php @@ -100,6 +100,10 @@ enum AuditActionId: string case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed'; case SupportDiagnosticsOpened = 'support_diagnostics.opened'; + case OperationalControlPaused = 'operational_control.paused'; + case OperationalControlUpdated = 'operational_control.updated'; + case OperationalControlResumed = 'operational_control.resumed'; + case OperationalControlExecutionBlocked = 'operational_control.execution_blocked'; // Workspace selection / switch events (Spec 107). case WorkspaceAutoSelected = 'workspace.auto_selected'; @@ -237,6 +241,10 @@ private static function labels(): array self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed', self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed', self::SupportDiagnosticsOpened->value => 'Support diagnostics opened', + self::OperationalControlPaused->value => 'Operational control paused', + self::OperationalControlUpdated->value => 'Operational control updated', + self::OperationalControlResumed->value => 'Operational control resumed', + self::OperationalControlExecutionBlocked->value => 'Operational control blocked execution', 'baseline.capture.started' => 'Baseline capture started', 'baseline.capture.completed' => 'Baseline capture completed', 'baseline.capture.failed' => 'Baseline capture failed', @@ -319,6 +327,10 @@ private static function summaries(): array self::TenantReviewExported->value => 'Tenant review exported', self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created', self::SupportDiagnosticsOpened->value => 'Support diagnostics opened', + self::OperationalControlPaused->value => 'Operational control paused', + self::OperationalControlUpdated->value => 'Operational control updated', + self::OperationalControlResumed->value => 'Operational control resumed', + self::OperationalControlExecutionBlocked->value => 'Operational control blocked execution', ]; } diff --git a/apps/platform/app/Support/Auth/PlatformCapabilities.php b/apps/platform/app/Support/Auth/PlatformCapabilities.php index ca7ced3c..e1575640 100644 --- a/apps/platform/app/Support/Auth/PlatformCapabilities.php +++ b/apps/platform/app/Support/Auth/PlatformCapabilities.php @@ -30,6 +30,8 @@ class PlatformCapabilities public const RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL = 'platform.runbooks.findings.lifecycle_backfill'; + public const OPS_CONTROLS_MANAGE = 'platform.ops.controls.manage'; + /** * @return array */ diff --git a/apps/platform/app/Support/OperationalControls/OperationalControlBlockedException.php b/apps/platform/app/Support/OperationalControls/OperationalControlBlockedException.php new file mode 100644 index 00000000..fd0db5ca --- /dev/null +++ b/apps/platform/app/Support/OperationalControls/OperationalControlBlockedException.php @@ -0,0 +1,31 @@ +reasonText ?? ''); + + parent::__construct($message !== '' + ? sprintf('%s is currently paused. %s', $actionLabel, $message) + : sprintf('%s is currently paused.', $actionLabel)); + } + + public static function forDecision(OperationalControlDecision $decision, string $actionLabel): self + { + return new self($decision, $actionLabel); + } + + public function title(): string + { + return sprintf('%s paused', $this->actionLabel); + } +} \ No newline at end of file diff --git a/apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php b/apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php new file mode 100644 index 00000000..414ea34f --- /dev/null +++ b/apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php @@ -0,0 +1,56 @@ +, operation_types: array, affected_surfaces: array}> + */ + private const DEFINITIONS = [ + 'restore.execute' => [ + 'key' => 'restore.execute', + 'label' => 'Restore execution', + 'supported_scopes' => ['global', 'workspace'], + 'operation_types' => ['restore.execute'], + 'affected_surfaces' => ['tenant.restore_runs.create'], + ], + ]; + + /** + * @return array + */ + public function keys(): array + { + return array_keys(self::DEFINITIONS); + } + + /** + * @return array> + */ + public function definitions(): array + { + return self::DEFINITIONS; + } + + /** + * @return array{key: string, label: string, supported_scopes: array, operation_types: array, affected_surfaces: array} + */ + public function definition(string $controlKey): array + { + $controlKey = trim($controlKey); + + if (! array_key_exists($controlKey, self::DEFINITIONS)) { + throw new \InvalidArgumentException("Unknown operational control [{$controlKey}]."); + } + + return self::DEFINITIONS[$controlKey]; + } + + public function label(string $controlKey): string + { + return $this->definition($controlKey)['label']; + } +} \ No newline at end of file diff --git a/apps/platform/app/Support/OperationalControls/OperationalControlDecision.php b/apps/platform/app/Support/OperationalControls/OperationalControlDecision.php new file mode 100644 index 00000000..14d0df90 --- /dev/null +++ b/apps/platform/app/Support/OperationalControls/OperationalControlDecision.php @@ -0,0 +1,81 @@ +effectiveState === 'enabled'; + } + + public function isPaused(): bool + { + return $this->effectiveState === 'paused'; + } + + public function hasWorkspaceScope(): bool + { + return $this->matchedScopeType === 'workspace' && $this->workspaceId !== null; + } + + public function scopeLabel(): string + { + return match ($this->matchedScopeType) { + 'global' => 'Global', + 'workspace' => $this->workspaceId !== null ? 'Workspace #'.$this->workspaceId : 'Workspace', + default => 'No active pause', + }; + } + + public function expiresAtIso8601(): ?string + { + return $this->expiresAt?->toIso8601String(); + } +} \ No newline at end of file diff --git a/apps/platform/app/Support/OperationalControls/OperationalControlEvaluator.php b/apps/platform/app/Support/OperationalControls/OperationalControlEvaluator.php new file mode 100644 index 00000000..3dc65fdb --- /dev/null +++ b/apps/platform/app/Support/OperationalControls/OperationalControlEvaluator.php @@ -0,0 +1,63 @@ +catalog->definition($controlKey); + $workspaceId = $workspace instanceof Workspace + ? (int) $workspace->getKey() + : (is_int($workspace) ? $workspace : null); + + $globalActivation = OperationalControlActivation::query() + ->forControl($definition['key']) + ->forGlobalScope() + ->notExpired() + ->latest('id') + ->first(); + + if ($globalActivation instanceof OperationalControlActivation) { + return OperationalControlDecision::paused( + controlKey: $definition['key'], + matchedScopeType: 'global', + workspaceId: null, + reasonText: $globalActivation->reason_text, + expiresAt: $globalActivation->expires_at, + sourceActivationId: (int) $globalActivation->getKey(), + ); + } + + if ($workspaceId !== null) { + $workspaceActivation = OperationalControlActivation::query() + ->forControl($definition['key']) + ->forWorkspaceScope($workspaceId) + ->notExpired() + ->latest('id') + ->first(); + + if ($workspaceActivation instanceof OperationalControlActivation) { + return OperationalControlDecision::paused( + controlKey: $definition['key'], + matchedScopeType: 'workspace', + workspaceId: $workspaceId, + reasonText: $workspaceActivation->reason_text, + expiresAt: $workspaceActivation->expires_at, + sourceActivationId: (int) $workspaceActivation->getKey(), + ); + } + } + + return OperationalControlDecision::enabled($definition['key']); + } +} \ No newline at end of file diff --git a/apps/platform/config/tenantpilot.php b/apps/platform/config/tenantpilot.php index ce4edfa3..d4ef8f1c 100644 --- a/apps/platform/config/tenantpilot.php +++ b/apps/platform/config/tenantpilot.php @@ -149,9 +149,6 @@ ], ], ], - - 'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false), - 'supported_policy_types' => [ [ 'type' => 'deviceConfiguration', diff --git a/apps/platform/database/factories/OperationalControlActivationFactory.php b/apps/platform/database/factories/OperationalControlActivationFactory.php new file mode 100644 index 00000000..ee938560 --- /dev/null +++ b/apps/platform/database/factories/OperationalControlActivationFactory.php @@ -0,0 +1,55 @@ + + */ +class OperationalControlActivationFactory extends Factory +{ + protected $model = OperationalControlActivation::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'control_key' => 'restore.execute', + 'scope_type' => 'global', + 'workspace_id' => null, + 'reason_text' => fake()->sentence(), + 'expires_at' => null, + 'created_by_platform_user_id' => PlatformUser::factory(), + 'updated_by_platform_user_id' => null, + ]; + } + + public function forControl(string $controlKey): static + { + return $this->state(fn (): array => [ + 'control_key' => $controlKey, + ]); + } + + public function forGlobalScope(): static + { + return $this->state(fn (): array => [ + 'scope_type' => 'global', + 'workspace_id' => null, + ]); + } + + public function workspaceScoped(): static + { + return $this->state(fn (): array => [ + 'scope_type' => 'workspace', + 'workspace_id' => Workspace::factory(), + ]); + } +} \ No newline at end of file diff --git a/apps/platform/database/migrations/2026_04_26_000000_create_operational_control_activations_table.php b/apps/platform/database/migrations/2026_04_26_000000_create_operational_control_activations_table.php new file mode 100644 index 00000000..291ea7f0 --- /dev/null +++ b/apps/platform/database/migrations/2026_04_26_000000_create_operational_control_activations_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('control_key'); + $table->string('scope_type'); + $table->foreignId('workspace_id')->nullable()->constrained('workspaces')->cascadeOnDelete(); + $table->text('reason_text'); + $table->timestampTz('expires_at')->nullable(); + $table->foreignId('created_by_platform_user_id')->constrained('platform_users')->restrictOnDelete(); + $table->foreignId('updated_by_platform_user_id')->nullable()->constrained('platform_users')->nullOnDelete(); + $table->timestamps(); + + $table->index(['control_key', 'scope_type']); + $table->index(['workspace_id', 'control_key']); + $table->index('expires_at'); + }); + + DB::statement("CREATE UNIQUE INDEX operational_control_activations_global_unique ON operational_control_activations (control_key) WHERE scope_type = 'global'"); + DB::statement("CREATE UNIQUE INDEX operational_control_activations_workspace_unique ON operational_control_activations (control_key, workspace_id) WHERE scope_type = 'workspace' AND workspace_id IS NOT NULL"); + } + + public function down(): void + { + Schema::dropIfExists('operational_control_activations'); + } +}; \ No newline at end of file diff --git a/apps/platform/database/seeders/PlatformUserSeeder.php b/apps/platform/database/seeders/PlatformUserSeeder.php index 060fe037..3585c630 100644 --- a/apps/platform/database/seeders/PlatformUserSeeder.php +++ b/apps/platform/database/seeders/PlatformUserSeeder.php @@ -42,6 +42,7 @@ public function run(): void PlatformCapabilities::RUNBOOKS_VIEW, PlatformCapabilities::RUNBOOKS_RUN, PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, + PlatformCapabilities::OPS_CONTROLS_MANAGE, ], 'is_active' => true, ], diff --git a/apps/platform/resources/views/filament/system/pages/ops/controls.blade.php b/apps/platform/resources/views/filament/system/pages/ops/controls.blade.php new file mode 100644 index 00000000..567dcf8e --- /dev/null +++ b/apps/platform/resources/views/filament/system/pages/ops/controls.blade.php @@ -0,0 +1,120 @@ +@php + $controls = $this->controlCards(); +@endphp + + +
+ +
+ + +
+

Runtime safety controls

+

+ Use these bounded operational controls to pause risky starts without hiding the underlying surface. Global pauses win over workspace-specific pauses. +

+
+
+
+ +
+ @foreach ($controls as $control) + + + {{ $control['label'] }} + + + + {{ implode(', ', $control['affected_surfaces']) }} + + + + + {{ $control['state_label'] }} + + + + @php + $pauseActionName = 'pause_'.$control['action_slug']; + $resumeActionName = 'resume_'.$control['action_slug']; + $historyActionName = 'view_history_'.$control['action_slug']; + @endphp + +
+
+ @foreach ($control['supported_scopes'] as $scope) + + {{ ucfirst($scope) }} + + @endforeach +
+ +
+ @if ($control['effective_state'] === 'paused') + + Resume + + @else + + Pause + + @endif + + + History + +
+ + @if ($control['active_activations'] !== []) +
+ @foreach ($control['active_activations'] as $activation) +
+
+ + {{ $activation['scope_label'] }} + + + + Owner: {{ $activation['owner_name'] }} + + + + {{ $activation['expires_label'] }} + +
+ +

+ {{ $activation['reason_text'] }} +

+
+ @endforeach +
+ @else +
+ No active pauses. New starts are currently enabled. +
+ @endif + +

+ Use the card actions to pause, resume, or inspect audit history for this control. +

+
+
+ @endforeach +
+
+
\ No newline at end of file diff --git a/apps/platform/resources/views/filament/system/pages/ops/partials/controls-header.blade.php b/apps/platform/resources/views/filament/system/pages/ops/partials/controls-header.blade.php new file mode 100644 index 00000000..1be4d1c7 --- /dev/null +++ b/apps/platform/resources/views/filament/system/pages/ops/partials/controls-header.blade.php @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/apps/platform/resources/views/filament/system/pages/ops/partials/operational-control-history.blade.php b/apps/platform/resources/views/filament/system/pages/ops/partials/operational-control-history.blade.php new file mode 100644 index 00000000..e6813e71 --- /dev/null +++ b/apps/platform/resources/views/filament/system/pages/ops/partials/operational-control-history.blade.php @@ -0,0 +1,29 @@ +
+ @if ($events->isEmpty()) +

+ No audit history exists yet for {{ $label }}. +

+ @else + @foreach ($events as $event) +
+
+ + {{ \App\Support\Audit\AuditActionId::labelFor((string) $event->action) }} + + + + {{ $event->recorded_at?->diffForHumans() ?? 'Unknown time' }} + + + + {{ $event->actorDisplayLabel() }} + +
+ +

+ {{ $event->summaryText() }} +

+
+ @endforeach + @endif +
\ No newline at end of file diff --git a/apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php b/apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php index 5309f923..de5815ba 100644 --- a/apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php +++ b/apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php @@ -9,12 +9,13 @@ uses(RefreshDatabase::class); -it('does not expose maintenance actions in /admin findings list by default', function () { +it('exposes the findings lifecycle backfill action for entitled tenant operators', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); $this->actingAs($user); Filament::setTenant($tenant, true); Livewire::test(ListFindings::class) - ->assertActionDoesNotExist('backfill_lifecycle'); + ->assertActionExists('backfill_lifecycle') + ->assertActionEnabled('backfill_lifecycle'); }); diff --git a/apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php b/apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php new file mode 100644 index 00000000..89545345 --- /dev/null +++ b/apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php @@ -0,0 +1,101 @@ +create([ + 'tenant_id' => (int) $tenant->getKey(), + 'due_at' => null, + ]); + + OperationalControlActivation::factory()->workspaceScoped()->create([ + 'control_key' => 'findings.lifecycle.backfill', + 'workspace_id' => (int) $tenant->workspace_id, + 'reason_text' => 'Workspace-specific pause.', + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListFindings::class) + ->assertActionExists('backfill_lifecycle') + ->assertActionEnabled('backfill_lifecycle') + ->callAction('backfill_lifecycle') + ->assertNotified('Findings lifecycle backfill paused'); + + expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0); + + $audit = AuditLog::query() + ->where('action', AuditActionId::OperationalControlExecutionBlocked->value) + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull() + ->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id) + ->and($audit?->tenant_id)->toBe((int) $tenant->getKey()) + ->and($audit?->status)->toBe('blocked') + ->and($audit?->metadata['control_key'] ?? null)->toBe('findings.lifecycle.backfill') + ->and($audit?->metadata['workspace_id'] ?? null)->toBe((int) $tenant->workspace_id); +}); + +it('does not block findings backfill for a different workspace when the pause is workspace-scoped', function (): void { + Queue::fake(); + + [$blockedUser, $blockedTenant] = createUserWithTenant(role: 'owner'); + [$allowedUser, $allowedTenant] = createUserWithTenant(role: 'owner'); + + Finding::factory()->create([ + 'tenant_id' => (int) $allowedTenant->getKey(), + 'due_at' => null, + ]); + + OperationalControlActivation::factory()->workspaceScoped()->create([ + 'control_key' => 'findings.lifecycle.backfill', + 'workspace_id' => (int) $blockedTenant->workspace_id, + 'reason_text' => 'Paused only for the blocked workspace.', + ]); + + $this->actingAs($allowedUser); + Filament::setTenant($allowedTenant, true); + + Livewire::test(ListFindings::class) + ->assertActionExists('backfill_lifecycle') + ->assertActionEnabled('backfill_lifecycle') + ->callAction('backfill_lifecycle'); + + $run = OperationRun::query() + ->where('type', 'findings.lifecycle.backfill') + ->where('tenant_id', (int) $allowedTenant->getKey()) + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + + Queue::assertPushed(BackfillFindingLifecycleJob::class, function (BackfillFindingLifecycleJob $job) use ($allowedTenant): bool { + return $job->tenantId === (int) $allowedTenant->getKey() + && $job->workspaceId === (int) $allowedTenant->workspace_id; + }); + + expect(AuditLog::query() + ->where('action', AuditActionId::OperationalControlExecutionBlocked->value) + ->where('tenant_id', (int) $allowedTenant->getKey()) + ->exists())->toBeFalse(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php b/apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php new file mode 100644 index 00000000..ee22abed --- /dev/null +++ b/apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php @@ -0,0 +1,69 @@ + $root.'/app/Filament/Resources/FindingResource/Pages/ListFindings.php', + 'required' => [ + 'FindingsLifecycleBackfillRunbookService', + 'OperationalControlBlockedException', + 'FindingsLifecycleBackfillScope::singleTenant(', + ], + 'forbidden' => [ + "config('tenantpilot.allow_admin_maintenance_actions'", + 'allow_admin_maintenance_actions', + 'OperationalControlActivation::', + ], + ], + [ + 'file' => $root.'/app/Filament/System/Pages/Ops/Runbooks.php', + 'required' => [ + 'FindingsLifecycleBackfillRunbookService', + 'OperationalControlBlockedException', + '$runbookService->start(', + ], + 'forbidden' => [ + 'OperationalControlActivation::', + "config('tenantpilot.allow_admin_maintenance_actions'", + ], + ], + [ + 'file' => $root.'/app/Filament/Resources/RestoreRunResource.php', + 'required' => [ + 'guardRestoreExecutionOperationalControl(', + 'OperationalControlEvaluator::class', + 'OperationalControlBlockedException', + ], + 'forbidden' => [ + 'OperationalControlActivation::', + "config('tenantpilot.allow_admin_maintenance_actions'", + ], + ], + [ + 'file' => $root.'/config/tenantpilot.php', + 'required' => [], + 'forbidden' => [ + 'allow_admin_maintenance_actions', + 'ALLOW_ADMIN_MAINTENANCE_ACTIONS', + ], + ], + ]; + + foreach ($checks as $check) { + $source = SourceFileScanner::read($check['file']); + + foreach ($check['required'] as $needle) { + expect($source)->toContain($needle); + } + + foreach ($check['forbidden'] as $needle) { + expect($source)->not->toContain($needle); + } + } +})->group('surface-guard'); \ No newline at end of file diff --git a/apps/platform/tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php b/apps/platform/tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php new file mode 100644 index 00000000..6173e288 --- /dev/null +++ b/apps/platform/tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php @@ -0,0 +1,133 @@ +create([ + 'tenant_id' => fake()->uuid(), + 'name' => 'Authorization Tenant', + 'rbac_status' => 'ok', + 'rbac_last_checked_at' => now(), + ]); + + $tenant->makeCurrent(); + ensureDefaultProviderConnection($tenant, 'microsoft'); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => fake()->uuid(), + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Authorization Restore Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Authorization Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => ['id' => $policy->external_id], + 'metadata' => ['displayName' => 'Authorization Restore Policy'], + ]); + + Filament::setTenant($tenant, true); + + return [$tenant, $backupSet, $backupItem]; +} + +it('keeps non-members at 404 even when restore execution is paused', function (): void { + [$tenant] = seedRestoreAuthorizationContext(); + + OperationalControlActivation::factory()->workspaceScoped()->create([ + 'control_key' => 'restore.execute', + 'workspace_id' => (int) $tenant->workspace_id, + 'reason_text' => 'Paused while access is under review.', + ]); + + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(RestoreRunResource::getUrl('create', panel: 'tenant', tenant: $tenant)) + ->assertNotFound(); +}); + +it('keeps members without tenant-manage at 403 even when restore execution is paused', function (): void { + [$tenant] = seedRestoreAuthorizationContext(); + + OperationalControlActivation::factory()->workspaceScoped()->create([ + 'control_key' => 'restore.execute', + 'workspace_id' => (int) $tenant->workspace_id, + 'reason_text' => 'Paused while access is under review.', + ]); + + [$user] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + $this->actingAs($user) + ->get(RestoreRunResource::getUrl('create', panel: 'tenant', tenant: $tenant)) + ->assertForbidden(); +}); + +it('shows paused-state feedback only to entitled users blocked by an operational control', function (): void { + [$tenant, $backupSet, $backupItem] = seedRestoreAuthorizationContext(); + + OperationalControlActivation::factory()->workspaceScoped()->create([ + 'control_key' => 'restore.execute', + 'workspace_id' => (int) $tenant->workspace_id, + 'reason_text' => 'Paused for tenant-safe validation.', + ]); + + [$user] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $this->actingAs($user); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ]) + ->goToNextWizardStep() + ->callFormComponentAction('check_results', 'run_restore_checks') + ->goToNextWizardStep() + ->callFormComponentAction('preview_diffs', 'run_restore_preview') + ->goToNextWizardStep() + ->fillForm([ + 'is_dry_run' => false, + 'acknowledged_impact' => true, + 'tenant_confirm' => 'Authorization Tenant', + ]) + ->call('create') + ->assertNotified('Restore execution paused'); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php b/apps/platform/tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php new file mode 100644 index 00000000..5444396a --- /dev/null +++ b/apps/platform/tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php @@ -0,0 +1,261 @@ +create(['name' => 'Restore Workspace']); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => fake()->uuid(), + 'name' => 'Restore Tenant', + 'rbac_status' => 'ok', + 'rbac_last_checked_at' => now(), + ]); + + $tenant->makeCurrent(); + + if ($withProviderConnection) { + ensureDefaultProviderConnection($tenant, 'microsoft'); + } + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => fake()->uuid(), + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Restore Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Restore Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => ['id' => $policy->external_id], + 'metadata' => ['displayName' => 'Restore Policy'], + ]); + + $user = User::factory()->create([ + 'email' => fake()->unique()->safeEmail(), + 'name' => 'Restore Operator', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); + + return [$tenant, $backupSet, $backupItem, $user, $workspace]; +} + +it('blocks restore execution before any operation run, restore run, job, or provider start is created', function (): void { + Bus::fake(); + + [$tenant, $backupSet, $backupItem, $user] = seedOperationalRestoreExecutionContext(); + + OperationalControlActivation::factory()->workspaceScoped()->create([ + 'control_key' => 'restore.execute', + 'workspace_id' => (int) $tenant->workspace_id, + 'reason_text' => 'Paused during restore safety review.', + ]); + + $this->actingAs($user); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ]) + ->goToNextWizardStep() + ->callFormComponentAction('check_results', 'run_restore_checks') + ->goToNextWizardStep() + ->callFormComponentAction('preview_diffs', 'run_restore_preview') + ->goToNextWizardStep() + ->fillForm([ + 'is_dry_run' => false, + 'acknowledged_impact' => true, + 'tenant_confirm' => 'Restore Tenant', + ]) + ->call('create') + ->assertNotified('Restore execution paused'); + + expect(RestoreRun::query()->count())->toBe(0) + ->and(OperationRun::query()->where('type', 'restore.execute')->count())->toBe(0); + + $audit = AuditLog::query() + ->where('action', AuditActionId::OperationalControlExecutionBlocked->value) + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull() + ->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id) + ->and($audit?->tenant_id)->toBe((int) $tenant->getKey()) + ->and($audit?->status)->toBe('blocked') + ->and($audit?->metadata['control_key'] ?? null)->toBe('restore.execute'); + + Bus::assertNotDispatched(ExecuteRestoreRunJob::class); +}); + +it('does not retroactively mutate already accepted restore execution runs when a later pause is activated', function (): void { + [$tenant, $backupSet, $backupItem, $user, $workspace] = seedOperationalRestoreExecutionContext(withProviderConnection: false); + + $operationRun = OperationRun::factory() + ->forTenant($tenant) + ->queued() + ->create([ + 'type' => 'restore.execute', + 'outcome' => 'pending', + 'initiator_name' => $user->name, + 'context' => [ + 'backup_set_id' => (int) $backupSet->getKey(), + 'target_scope' => ['entra_tenant_id' => $tenant->graphTenantId()], + ], + ]); + + $restoreRun = RestoreRun::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'operation_run_id' => (int) $operationRun->getKey(), + 'requested_by' => $user->email, + 'is_dry_run' => false, + 'status' => RestoreRunStatus::Queued->value, + 'idempotency_key' => 'accepted-before-pause', + 'requested_items' => [(int) $backupItem->getKey()], + 'preview' => ['summary' => []], + 'metadata' => ['confirmed_by' => $user->email], + 'group_mapping' => [], + ]); + + $platformUser = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPS_CONTROLS_MANAGE, + ], + 'is_active' => true, + ]); + + Filament::setCurrentPanel('system'); + Filament::bootCurrentPanel(); + $this->actingAs($platformUser, 'platform'); + + Livewire::test(Controls::class) + ->callAction('pause_restore_execute', data: [ + 'scope_type' => 'workspace', + 'workspace_id' => (int) $workspace->getKey(), + 'reason_text' => 'Pause after the run was already accepted.', + 'expires_at' => now()->addHour()->toDateTimeString(), + ]) + ->assertNotified('Restore execution paused'); + + expect($operationRun->fresh()) + ->not->toBeNull() + ->and($operationRun->fresh()?->status)->toBe('queued') + ->and($operationRun->fresh()?->outcome)->toBe('pending'); + + expect($restoreRun->fresh()) + ->not->toBeNull() + ->and($restoreRun->fresh()?->status)->toBe(RestoreRunStatus::Queued->value) + ->and((int) ($restoreRun->fresh()?->operation_run_id ?? 0))->toBe((int) $operationRun->getKey()); +}); + +it('does not block restore execution for a different workspace when the pause is workspace-scoped', function (): void { + Bus::fake(); + + $blockedWorkspace = Workspace::factory()->create(['name' => 'Blocked Workspace']); + $allowedWorkspace = Workspace::factory()->create(['name' => 'Allowed Workspace']); + + [$blockedTenant] = seedOperationalRestoreExecutionContext(workspace: $blockedWorkspace); + [$allowedTenant, $backupSet, $backupItem, $user] = seedOperationalRestoreExecutionContext(workspace: $allowedWorkspace); + + OperationalControlActivation::factory()->workspaceScoped()->create([ + 'control_key' => 'restore.execute', + 'workspace_id' => (int) $blockedTenant->workspace_id, + 'reason_text' => 'Paused only for the blocked workspace.', + ]); + + $this->actingAs($user); + Filament::setTenant($allowedTenant, true); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ]) + ->goToNextWizardStep() + ->callFormComponentAction('check_results', 'run_restore_checks') + ->goToNextWizardStep() + ->callFormComponentAction('preview_diffs', 'run_restore_preview') + ->goToNextWizardStep() + ->fillForm([ + 'is_dry_run' => false, + 'acknowledged_impact' => true, + 'tenant_confirm' => 'Restore Tenant', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $restoreRun = RestoreRun::query() + ->where('tenant_id', (int) $allowedTenant->getKey()) + ->latest('id') + ->first(); + + $operationRun = OperationRun::query() + ->where('tenant_id', (int) $allowedTenant->getKey()) + ->where('type', 'restore.execute') + ->latest('id') + ->first(); + + expect($restoreRun)->not->toBeNull() + ->and($operationRun)->not->toBeNull(); + + Bus::assertDispatched(ExecuteRestoreRunJob::class); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php b/apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php new file mode 100644 index 00000000..26248450 --- /dev/null +++ b/apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php @@ -0,0 +1,243 @@ +create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPS_CONTROLS_MANAGE, + ], + 'is_active' => true, + ]); +} + +it('returns 403 for platform users missing the operational controls capability', function (): void { + $user = PlatformUser::factory()->create([ + 'capabilities' => [PlatformCapabilities::ACCESS_SYSTEM_PANEL], + 'is_active' => true, + ]); + + $this->actingAs($user, 'platform') + ->get(Controls::getUrl(panel: 'system')) + ->assertForbidden(); +}); + +it('renders compact card actions and only shows the action that matches the current control state', function (): void { + $user = makeControlsManager(); + $this->actingAs($user, 'platform'); + + $this->get(Controls::getUrl(panel: 'system')) + ->assertSuccessful() + ->assertSee("mountAction('pause_restore_execute')", escape: false) + ->assertDontSee('Findings lifecycle backfill') + ->assertDontSee("mountAction('pause_findings_lifecycle_backfill')", escape: false) + ->assertDontSee("mountAction('resume_findings_lifecycle_backfill')", escape: false) + ->assertDontSee("mountAction('view_history_findings_lifecycle_backfill')", escape: false) + ->assertDontSee('Pause Restore execution') + ->assertDontSee('Resume Restore execution'); + + OperationalControlActivation::factory()->forGlobalScope()->create([ + 'control_key' => 'restore.execute', + 'reason_text' => 'Paused for compact action rendering coverage.', + ]); + + $this->get(Controls::getUrl(panel: 'system')) + ->assertSuccessful() + ->assertSee("mountAction('resume_restore_execute')", escape: false) + ->assertDontSee("mountAction('pause_restore_execute')", escape: false) + ->assertDontSee('Findings lifecycle backfill'); +}); + +it('previews, pauses, updates, resumes, and exposes on-demand history for restore execution', function (): void { + $workspaceA = Workspace::factory()->create(['name' => 'Acme']); + $workspaceB = Workspace::factory()->create(['name' => 'Bravo']); + + Tenant::factory()->count(2)->create(['workspace_id' => (int) $workspaceA->getKey()]); + Tenant::factory()->count(1)->create(['workspace_id' => (int) $workspaceB->getKey()]); + + $user = makeControlsManager(); + $this->actingAs($user, 'platform'); + + $component = Livewire::test(Controls::class) + ->assertActionExists('pause_restore_execute', fn (Action $action): bool => $action->isConfirmationRequired()) + ->assertActionExists('resume_restore_execute', fn (Action $action): bool => $action->isConfirmationRequired()) + ->assertActionExists('view_history_restore_execute', fn (Action $action): bool => $action->getLabel() === 'View Restore execution history'); + + $preview = $component->instance()->scopeImpactPreview('restore.execute', 'global', null); + + expect($preview['workspace_count'])->toBe(2) + ->and($preview['tenant_count'])->toBe(3) + ->and($preview['summary'])->toContain('2 workspaces') + ->and($preview['summary'])->toContain('3 tenants'); + + $component + ->callAction('pause_restore_execute', data: [ + 'scope_type' => 'global', + 'reason_text' => 'Paused for incident review.', + 'expires_at' => now()->addDay()->toDateTimeString(), + ]) + ->assertNotified('Restore execution paused'); + + $activation = OperationalControlActivation::query() + ->forControl('restore.execute') + ->forGlobalScope() + ->first(); + + expect($activation)->not->toBeNull() + ->and($activation?->reason_text)->toBe('Paused for incident review.'); + + $summary = $component->instance()->controlSummary('restore.execute'); + + expect($summary['effective_state'])->toBe('paused') + ->and($summary['active_activations'])->toHaveCount(1) + ->and($summary['active_activations'][0]['owner_name'])->toBe($user->name); + + $component + ->callAction('pause_restore_execute', data: [ + 'scope_type' => 'global', + 'reason_text' => 'Updated incident review scope.', + 'expires_at' => now()->addDays(2)->toDateTimeString(), + ]) + ->assertNotified('Restore execution updated'); + + expect($activation?->fresh()?->reason_text)->toBe('Updated incident review scope.'); + + $component + ->callAction('resume_restore_execute', data: [ + 'scope_type' => 'global', + ]) + ->assertNotified('Restore execution resumed'); + + expect(OperationalControlActivation::query() + ->forControl('restore.execute') + ->forGlobalScope() + ->count())->toBe(0); + + $audits = AuditLog::query() + ->whereIn('action', [ + AuditActionId::OperationalControlPaused->value, + AuditActionId::OperationalControlUpdated->value, + AuditActionId::OperationalControlResumed->value, + ]) + ->where('metadata->control_key', 'restore.execute') + ->orderBy('id') + ->get(); + + expect($audits)->toHaveCount(3) + ->and($audits->pluck('workspace_id')->unique()->all())->toBe([null]) + ->and($audits->pluck('tenant_id')->unique()->all())->toBe([null]) + ->and($audits[0]->action)->toBe(AuditActionId::OperationalControlPaused->value) + ->and($audits[1]->action)->toBe(AuditActionId::OperationalControlUpdated->value) + ->and($audits[2]->action)->toBe(AuditActionId::OperationalControlResumed->value); + + $component + ->mountAction('view_history_restore_execute') + ->assertActionMounted('view_history_restore_execute'); +}); + +it('supports workspace-scoped pauses and removes expired conflicting activations before replacement writes', function (): void { + $workspaceA = Workspace::factory()->create(['name' => 'Acme']); + $workspaceB = Workspace::factory()->create(['name' => 'Bravo']); + + Tenant::factory()->count(2)->create(['workspace_id' => (int) $workspaceA->getKey()]); + Tenant::factory()->count(1)->create(['workspace_id' => (int) $workspaceB->getKey()]); + + $expired = OperationalControlActivation::factory()->workspaceScoped()->create([ + 'control_key' => 'restore.execute', + 'workspace_id' => (int) $workspaceA->getKey(), + 'reason_text' => 'Expired pause.', + 'expires_at' => now()->subHour(), + ]); + + $user = makeControlsManager(); + $this->actingAs($user, 'platform'); + + $component = Livewire::test(Controls::class) + ->assertActionExists('pause_restore_execute', fn (Action $action): bool => $action->isConfirmationRequired()) + ->assertActionExists('resume_restore_execute', fn (Action $action): bool => $action->isConfirmationRequired()); + + $preview = $component->instance()->scopeImpactPreview('restore.execute', 'workspace', (int) $workspaceA->getKey()); + + expect($preview['workspace_count'])->toBe(1) + ->and($preview['tenant_count'])->toBe(2) + ->and($preview['summary'])->toContain('Acme'); + + $component + ->callAction('pause_restore_execute', data: [ + 'scope_type' => 'workspace', + 'workspace_id' => (int) $workspaceA->getKey(), + 'reason_text' => 'Paused for workspace restore maintenance.', + 'expires_at' => now()->addDay()->toDateTimeString(), + ]) + ->assertNotified('Restore execution paused'); + + expect(OperationalControlActivation::query()->whereKey((int) $expired->getKey())->exists())->toBeFalse(); + + $activation = OperationalControlActivation::query() + ->forControl('restore.execute') + ->forWorkspaceScope((int) $workspaceA->getKey()) + ->notExpired() + ->first(); + + expect($activation)->not->toBeNull() + ->and((int) ($activation?->workspace_id ?? 0))->toBe((int) $workspaceA->getKey()); + + $component + ->callAction('pause_restore_execute', data: [ + 'scope_type' => 'workspace', + 'workspace_id' => (int) $workspaceA->getKey(), + 'reason_text' => 'Updated workspace restore maintenance.', + 'expires_at' => now()->addDays(3)->toDateTimeString(), + ]) + ->assertNotified('Restore execution updated'); + + expect($activation?->fresh()?->reason_text)->toBe('Updated workspace restore maintenance.'); + + $component + ->callAction('resume_restore_execute', data: [ + 'scope_type' => 'workspace', + 'workspace_id' => (int) $workspaceA->getKey(), + ]) + ->assertNotified('Restore execution resumed'); + + expect(OperationalControlActivation::query() + ->forControl('restore.execute') + ->forWorkspaceScope((int) $workspaceA->getKey()) + ->count())->toBe(0); + + $audits = AuditLog::query() + ->whereIn('action', [ + AuditActionId::OperationalControlPaused->value, + AuditActionId::OperationalControlUpdated->value, + AuditActionId::OperationalControlResumed->value, + ]) + ->where('metadata->control_key', 'restore.execute') + ->orderBy('id') + ->get(); + + expect($audits)->toHaveCount(3) + ->and($audits[0]->workspace_id)->toBe((int) $workspaceA->getKey()) + ->and($audits[0]->tenant_id)->toBeNull(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php new file mode 100644 index 00000000..3c61ef99 --- /dev/null +++ b/apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php @@ -0,0 +1,89 @@ +create([ + 'tenant_id' => null, + 'external_id' => 'platform', + 'name' => 'Platform', + ]); +}); + +it('blocks all-tenant findings lifecycle runbooks when the control is globally paused', function (): void { + Queue::fake(); + + $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $platformTenant->workspace_id, + ]); + + Finding::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'due_at' => null, + ]); + + OperationalControlActivation::factory()->forGlobalScope()->create([ + 'control_key' => 'findings.lifecycle.backfill', + 'reason_text' => 'Paused during incident response.', + ]); + + $user = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPS_VIEW, + PlatformCapabilities::RUNBOOKS_VIEW, + PlatformCapabilities::RUNBOOKS_RUN, + PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, + ], + 'is_active' => true, + ]); + + $this->actingAs($user, 'platform'); + + Livewire::test(Runbooks::class) + ->callAction('preflight', data: [ + 'scope_mode' => 'all_tenants', + ]) + ->assertSet('preflight.affected_count', 1) + ->callAction('run', data: [ + 'typed_confirmation' => 'BACKFILL', + 'reason_code' => 'DATA_REPAIR', + 'reason_text' => 'Attempt blocked by control', + ]) + ->assertNotified('Findings lifecycle backfill paused'); + + expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0); + + $audit = AuditLog::query() + ->where('action', AuditActionId::OperationalControlExecutionBlocked->value) + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull() + ->and($audit?->workspace_id)->toBeNull() + ->and($audit?->tenant_id)->toBeNull() + ->and($audit?->status)->toBe('blocked') + ->and($audit?->metadata['control_key'] ?? null)->toBe('findings.lifecycle.backfill') + ->and($audit?->metadata['requested_scope'] ?? null)->toBe('all_tenants'); +}); \ No newline at end of file diff --git a/apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php b/apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php new file mode 100644 index 00000000..2074291c --- /dev/null +++ b/apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php @@ -0,0 +1,26 @@ +keys())->toBe(['restore.execute']) + ->and($catalog->definition('restore.execute'))->toMatchArray([ + 'key' => 'restore.execute', + 'label' => 'Restore execution', + 'supported_scopes' => ['global', 'workspace'], + 'operation_types' => ['restore.execute'], + ]); +}); + +it('rejects removed or unknown control keys', function (): void { + $catalog = app(OperationalControlCatalog::class); + + expect(fn (): array => $catalog->definition('findings.lifecycle.backfill')) + ->toThrow(\InvalidArgumentException::class) + ->and(fn (): array => $catalog->definition('tenant.review.compose')) + ->toThrow(\InvalidArgumentException::class); +}); \ No newline at end of file diff --git a/apps/platform/tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php b/apps/platform/tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php new file mode 100644 index 00000000..32f1f634 --- /dev/null +++ b/apps/platform/tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php @@ -0,0 +1,45 @@ +create(); + + $decision = app(OperationalControlEvaluator::class)->evaluate('restore.execute', $workspace); + + expect($decision->isEnabled())->toBeTrue() + ->and($decision->effectiveState)->toBe('enabled') + ->and($decision->scopeLabel())->toBe('No active pause') + ->and($decision->matchedScopeType)->toBe('none') + ->and($decision->workspaceId)->toBeNull() + ->and($decision->reasonText)->toBeNull() + ->and($decision->sourceActivationId)->toBeNull(); +}); + +it('returns the matching workspace pause when present', function (): void { + $workspace = Workspace::factory()->create(); + + $activation = OperationalControlActivation::factory()->workspaceScoped()->create([ + 'control_key' => 'restore.execute', + 'workspace_id' => (int) $workspace->getKey(), + 'reason_text' => 'Restore execution is paused for this workspace.', + ]); + + $decision = app(OperationalControlEvaluator::class)->evaluate('restore.execute', $workspace); + + expect($decision->isPaused())->toBeTrue() + ->and($decision->effectiveState)->toBe('paused') + ->and($decision->hasWorkspaceScope())->toBeTrue() + ->and($decision->scopeLabel())->toBe('Workspace #'.(int) $workspace->getKey()) + ->and($decision->matchedScopeType)->toBe('workspace') + ->and($decision->workspaceId)->toBe((int) $workspace->getKey()) + ->and($decision->reasonText)->toBe('Restore execution is paused for this workspace.') + ->and($decision->sourceActivationId)->toBe((int) $activation->getKey()); +}); \ No newline at end of file diff --git a/apps/platform/tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php b/apps/platform/tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php new file mode 100644 index 00000000..f69096ec --- /dev/null +++ b/apps/platform/tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php @@ -0,0 +1,57 @@ +create(); + + OperationalControlActivation::factory()->workspaceScoped()->create([ + 'control_key' => 'restore.execute', + 'workspace_id' => (int) $workspace->getKey(), + 'reason_text' => 'Workspace pause.', + ]); + + $globalActivation = OperationalControlActivation::factory()->forGlobalScope()->create([ + 'control_key' => 'restore.execute', + 'reason_text' => 'Global incident pause.', + ]); + + $decision = app(OperationalControlEvaluator::class)->evaluate('restore.execute', $workspace); + + expect($decision->isPaused())->toBeTrue() + ->and($decision->matchedScopeType)->toBe('global') + ->and($decision->workspaceId)->toBeNull() + ->and($decision->reasonText)->toBe('Global incident pause.') + ->and($decision->sourceActivationId)->toBe((int) $globalActivation->getKey()); +}); + +it('ignores expired global activations when resolving the effective state', function (): void { + $workspace = Workspace::factory()->create(); + + OperationalControlActivation::factory()->forGlobalScope()->create([ + 'control_key' => 'restore.execute', + 'reason_text' => 'Expired global pause.', + 'expires_at' => now()->subMinute(), + ]); + + $workspaceActivation = OperationalControlActivation::factory()->workspaceScoped()->create([ + 'control_key' => 'restore.execute', + 'workspace_id' => (int) $workspace->getKey(), + 'reason_text' => 'Active workspace pause.', + ]); + + $decision = app(OperationalControlEvaluator::class)->evaluate('restore.execute', $workspace); + + expect($decision->isPaused())->toBeTrue() + ->and($decision->matchedScopeType)->toBe('workspace') + ->and($decision->workspaceId)->toBe((int) $workspace->getKey()) + ->and($decision->reasonText)->toBe('Active workspace pause.') + ->and($decision->sourceActivationId)->toBe((int) $workspaceActivation->getKey()); +}); \ No newline at end of file diff --git a/specs/242-operational-controls/checklists/requirements.md b/specs/242-operational-controls/checklists/requirements.md new file mode 100644 index 00000000..af534d3c --- /dev/null +++ b/specs/242-operational-controls/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Operational Controls + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-26 +**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 + +- Selection rationale and scope narrowing are documented directly in `spec.md` so planning can proceed without a separate clarification pass. \ No newline at end of file diff --git a/specs/242-operational-controls/contracts/operational-controls.contract.yaml b/specs/242-operational-controls/contracts/operational-controls.contract.yaml new file mode 100644 index 00000000..d840f5de --- /dev/null +++ b/specs/242-operational-controls/contracts/operational-controls.contract.yaml @@ -0,0 +1,153 @@ +version: 1 +kind: operational-controls + +catalog: + control_keys: + findings.lifecycle.backfill: + label: Findings lifecycle backfill + supported_scopes: + - global + - workspace + operation_types: + - findings.lifecycle.backfill + affected_surfaces: + - system.ops.runbooks + - tenant.findings.list + restore.execute: + label: Restore execution + supported_scopes: + - global + - workspace + operation_types: + - restore.execute + affected_surfaces: + - tenant.restore_runs.create + +activation_record: + table: operational_control_activations + fields: + id: integer + control_key: string + scope_type: + type: string + allowed: + - global + - workspace + workspace_id: + type: integer + nullable: true + reason_text: string + expires_at: + type: datetime + nullable: true + created_by_platform_user_id: integer + updated_by_platform_user_id: + type: integer + nullable: true + display_rules: + owner_actor: updated_by_platform_user_id when present, otherwise created_by_platform_user_id + invariants: + - one active row per control_key + scope_type + workspace_id + - workspace_id is null for global rows + - enabled state is derived from no active matching row + persistence_notes: + - enforce one active global row per control_key with a partial unique index where scope_type = global + - enforce one active workspace row per control_key + workspace_id with a partial unique index where scope_type = workspace + - delete expired conflicting rows before inserting a new activation for the same control/scope + - do not use this table as an archive of expired activations + +management_commands: + pause_control: + required_platform_capabilities: + - platform.access_system_panel + - platform.ops.controls.manage + safety_flow: + - configure scope and reason + - preview scope impact + - confirm mutation + input: + control_key: string + scope_type: global|workspace + workspace_id: integer|null + reason_text: string + expires_at: datetime|null + outcome: + activation_created_or_updated: true + audit_action: operational_control.paused|operational_control.updated + + resume_control: + required_platform_capabilities: + - platform.access_system_panel + - platform.ops.controls.manage + safety_flow: + - review current scope impact + - confirm mutation + input: + control_key: string + scope_type: global|workspace + workspace_id: integer|null + outcome: + activation_removed: true + audit_action: operational_control.resumed + +decision_output: + fields: + control_key: string + effective_state: enabled|paused + matched_scope_type: none|global|workspace + workspace_id: integer|null + reason_text: string|null + expires_at: datetime|null + source_activation_id: integer|null + guarantees: + - returned before any in-scope start is allowed to continue + - blocked decisions create no queued execution OperationRun, no queued execution RestoreRun, no queued job, and no provider-backed execution + - control activation governs new starts only and does not mutate previously accepted runs + +evaluation_rules: + precedence: + - active global activation wins over any workspace activation for the same control key + - workspace activation applies only when no active global activation matches + expiry: + - expired activations are ignored + disclosure: + - tenant/admin surfaces disclose control-state details only after membership and capability scope are resolved + +enforcement_targets: + - control_key: findings.lifecycle.backfill + target: + seam: service.runbooks.findings_lifecycle_backfill.start + callers: + - system.ops.runbooks + - tenant.findings.list + - console.tenantpilot.findings.backfill-lifecycle + - console.tenantpilot.run-deploy-runbooks + action: Start findings lifecycle backfill + operation_type: findings.lifecycle.backfill + - control_key: restore.execute + target: + surface: tenant.restore_runs.create + action: Execute restore + operation_type: restore.execute + +audit_expectations: + action_ids: + - operational_control.paused + - operational_control.updated + - operational_control.resumed + - operational_control.execution_blocked + required_metadata: + - control_key + - scope_type + - workspace_id + - reason_text + - expires_at + - actor_id + event_specific_metadata: + blocked_system_all_tenant_execution_events: + - requested_scope + ownership: + global_control_changes: platform-plane event with null workspace_id and null tenant_id + workspace_control_changes: workspace-scoped event + blocked_execution_events: scoped to the affected workspace and tenant when a tenant is in context + blocked_system_all_tenant_execution_events: platform-plane event with null workspace_id and null tenant_id plus requested_scope metadata \ No newline at end of file diff --git a/specs/242-operational-controls/data-model.md b/specs/242-operational-controls/data-model.md new file mode 100644 index 00000000..95aa0164 --- /dev/null +++ b/specs/242-operational-controls/data-model.md @@ -0,0 +1,164 @@ +# Data Model — Operational Controls + +**Spec**: [spec.md](spec.md) + +The first operational-controls slice adds one persisted runtime-safety record and two derived runtime concepts. It reuses existing execution and audit truth. + +## Existing Canonical Entities Reused + +### Workspace (`workspaces`) + +**Purpose**: Existing workspace boundary for targeted operational-control scope. + +**Key fields (existing)**: +- `id` +- `name` + +**Feature use**: +- Identifies the workspace targeted by a workspace-scoped control activation. +- Continues to anchor workspace isolation and audit scope. + +### Tenant (`tenants`) + +**Purpose**: Existing tenant boundary for the affected execution surfaces. + +**Key fields (existing)**: +- `id` +- `workspace_id` +- `name` +- `external_id` + +**Feature use**: +- Supplies workspace context for findings and restore execution checks. +- Does not own control records in this slice. + +### PlatformUser (`platform_users` or equivalent platform-authenticated user model) + +**Purpose**: Existing platform-plane actor for control management. + +**Feature use**: +- Owns pause/resume actions in the system plane. +- Supplies actor identity for audit and attribution on control changes. + +### OperationRun (`operation_runs`) + +**Purpose**: Existing canonical execution truth for in-scope starts when execution is allowed. + +**Key fields (existing)**: +- `id` +- `workspace_id` +- `tenant_id` +- `type` +- `status` +- `outcome` +- `context` + +**Feature use**: +- Remains the only execution truth for allowed starts. +- Must not be created when an in-scope start is blocked by an active control. +- Existing queued or historical `OperationRun` records remain unchanged when a later control activation blocks only new starts. + +### RestoreRun (`restore_runs`) + +**Purpose**: Existing restore execution truth for queued restore work. + +**Feature use**: +- No new queued execution `RestoreRun` is created by a blocked `restore.execute` start path. +- Continues to link to `OperationRun` only when execution is allowed. + +### AuditLog (`audit_logs`) + +**Purpose**: Existing audit truth for control changes and blocked execution evidence. + +**Feature use**: +- Records pause, update, resume, and blocked-execution events with stable action IDs. +- Avoids introducing a second historical record model for the first slice. + +## New Persisted Entity + +### OperationalControlActivation (`operational_control_activations`) + +**Purpose**: The active runtime-safety record that pauses one bounded control key for either all workspaces or one specific workspace. + +**Key fields**: +- `id` +- `control_key` — bounded to the first-slice catalog keys `findings.lifecycle.backfill` and `restore.execute` +- `scope_type` — `global` or `workspace` +- `workspace_id` — nullable; required when `scope_type = workspace` +- `reason_text` +- `expires_at` — nullable +- `created_by_platform_user_id` +- `updated_by_platform_user_id` — nullable +- `created_at` +- `updated_at` + +**Display rule**: +- `owner` on the controls surface resolves to `updated_by_platform_user_id` when present, otherwise `created_by_platform_user_id`. + +**Constraints**: +- At most one active row per `control_key + scope_type + workspace_id` combination. +- `workspace_id` must be null for `global` scope and present for `workspace` scope. +- Expired rows are ignored by the evaluator. +- PostgreSQL uniqueness is enforced with partial unique indexes: one active global row per `control_key` where `scope_type = global`, and one active workspace row per `control_key + workspace_id` where `scope_type = workspace`. +- Writes must delete expired conflicting rows before inserting a new activation so ignored expired rows do not block a new active pause. + +**Lifecycle**: +- Created when a control is paused. +- Updated when reason or expiry changes. +- Expired rows are deleted by the write path before a replacement activation for the same control/scope is inserted. +- Removed when the control is resumed. +- No explicit `enabled` rows are stored; enabled is derived from no active matching row. + +**Relationships**: +- Optionally `belongsTo Workspace` +- `createdBy` / `updatedBy` platform-user relations if the existing platform-user model supports them + +## Derived Runtime Entities + +### OperationalControlDefinition (derived, not persisted) + +**Purpose**: Catalog metadata for one controllable risky action. + +**Proposed runtime fields**: +- `key` +- `label` +- `supported_scopes` +- `operation_types` +- `affected_surfaces` +- `default_state` (derived `enabled`) + +**Feature use**: +- Drives the controls page and evaluator without turning the catalog into a user-managed taxonomy. + +### OperationalControlDecision (derived, not persisted) + +**Purpose**: The evaluated result returned to an affected surface or service start seam. + +**Proposed runtime fields**: +- `control_key` +- `effective_state` — `enabled` or `paused` +- `matched_scope_type` — `global`, `workspace`, or `none` +- `workspace_id` — nullable +- `reason_text` — nullable when enabled +- `expires_at` — nullable +- `source_activation_id` — nullable + +**Feature use**: +- Tells a surface whether execution may proceed. +- Supplies one shared reason for blocked-state messaging and audit context. + +## Evaluation Rules + +- The evaluator resolves workspace context before checking control scope. +- A matching global activation wins over a workspace activation in v1. Workspace-scoped activations only take effect when no active global activation exists for the same control. +- Expired activations do not block execution. +- Missing entitlement or missing capability is resolved before control-state disclosure on tenant/admin surfaces. + +## Data Ownership Notes + +- No tenant-owned control records are introduced in the first slice. +- Control activations are platform-operated runtime-safety truth. +- Global control changes audit as platform-plane events with null workspace/tenant ownership. +- Workspace-targeted changes and blocked execution events with concrete workspace/tenant context retain truthful workspace/tenant audit scope. +- Blocked system-plane all-tenant attempts audit as platform-plane events with null workspace/tenant ownership plus requested-scope metadata. +- Tenant/admin surfaces consume only the derived decision, never direct activation editing. \ No newline at end of file diff --git a/specs/242-operational-controls/plan.md b/specs/242-operational-controls/plan.md new file mode 100644 index 00000000..a5817f68 --- /dev/null +++ b/specs/242-operational-controls/plan.md @@ -0,0 +1,232 @@ +# Implementation Plan: Operational Controls + +**Branch**: `242-operational-controls` | **Date**: 2026-04-26 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +- Replace the ad-hoc `allow_admin_maintenance_actions` environment gate with one product-owned operational-control path for the first-slice keys `findings.lifecycle.backfill` and `restore.execute`. +- Introduce one platform-operated activation record plus one shared evaluator that plugs into the existing system runbook, tenant findings-maintenance, and restore-execution start seams without becoming a generic experimentation platform. +- Reuse existing enforcement and UX seams - `UiEnforcement`, `ProviderOperationStartGate`, `OperationRunService`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, `AuditRecorder`, `WorkspaceAuditLogger`, and `AuditActionId` - so the slice stays small, auditable, and server-side enforced. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `ProviderOperationStartGate`, `OperationRunService`, `AuditRecorder`, `WorkspaceAuditLogger`, `AuditActionId`, `PlatformCapabilities` +**Storage**: PostgreSQL via existing product tables plus one new platform-operated `operational_control_activations` table; no tenant-owned control tables +**Testing**: Pest unit + feature tests only +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Sail-backed Laravel admin surfaces under `/admin/t/{tenant}` and system surfaces under `/system` +**Project Type**: web +**Performance Goals**: effective-control resolution remains DB-only and cheap at action start time, adds no outbound HTTP, and blocks in-scope starts before queue or provider execution begins +**Constraints**: no generic feature-flag platform, no new browser or heavy-governance suite, no break-glass bypass in v1, no parallel env gate for in-scope controls, global pauses win over workspace pauses, preserve 404 vs 403 semantics, keep provider-specific restore behavior out of platform-core control vocabulary +**Scale/Scope**: 2 control keys, 2 scope levels (global and workspace), 1 system management surface, and 3 concrete enforcement families across 4 touched UI surfaces + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament + shared start/result primitives +- **Shared-family relevance**: header actions, runbook launch actions, provider-backed start results, audit-backed control changes +- **State layers in scope**: page, detail, action/modal +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: standard-native-filament, monitoring-state-page +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none; v1 must not allow a second local runtime-control dialect +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `App\Filament\System\Pages\Ops\Runbooks`, new system ops controls page, `App\Filament\Resources\FindingResource\Pages\ListFindings`, `App\Filament\Resources\RestoreRunResource`, `App\Support\Rbac\UiEnforcement`, `App\Services\Providers\ProviderOperationStartGate`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ProviderOperationStartResultPresenter`, `App\Services\Audit\AuditRecorder`, `App\Services\Audit\WorkspaceAuditLogger`, `App\Support\Audit\AuditActionId` +- **Shared abstractions reused**: `UiEnforcement`, `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, `OperationRunService`, `OperationUxPresenter`, `OpsUxBrowserEvents`, `OperationRunLinks`, `SystemOperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger` +- **New abstraction introduced? why?**: one bounded `OperationalControlCatalog` plus one `OperationalControlEvaluator` are justified because the feature now has two real concrete control keys that must evaluate consistently across system-plane and tenant-plane start paths. No registry lattice, provider strategy system, or customer-facing flag DSL is introduced. +- **Why the existing abstraction was sufficient or insufficient**: existing abstractions already own auth, queue start UX, and audit writing; they are insufficient because none presently carries a reusable runtime-safety decision that can pause an action before it starts, and `WorkspaceAuditLogger` alone cannot truthfully own global platform-plane mutations. +- **Bounded deviation / spread control**: no deviation is allowed for in-scope controls; every affected surface must route through the shared evaluator rather than direct `config(...)` reads or page-local booleans. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes +- **Central contract reused**: shared OperationRun start UX plus provider-start result helpers +- **Delegated UX behaviors**: queued toast, `Open operation` / `View run` links, run-enqueued browser event, dedupe-or-blocked messaging, and tenant/workspace-safe URL resolution remain on existing shared paths +- **Surface-owned behavior kept local**: initiation inputs, confirmation copy, and control-management forms only +- **Queued DB-notification policy**: unchanged explicit opt-in only +- **Terminal notification path**: existing central lifecycle mechanism for starts that are allowed +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes +- **Provider-owned seams**: provider-backed `restore.execute` dispatch, provider binding resolution, provider reason translation, existing restore safety and dry-run behavior +- **Platform-core seams**: operational-control vocabulary, scope/effective-state evaluation, control management surface, audit labels, blocked-state semantics +- **Neutral platform terms / contracts preserved**: operational control, activation, effective state, scope, reason, expiry, blocked execution +- **Retained provider-specific semantics and why**: `restore.execute` remains Microsoft-specific provider behavior in the current release because the control feature governs only start allowance, not provider execution semantics +- **Bounded extraction or follow-up path**: none in this slice; future catalog growth or provider-neutral expansions require a follow-up spec instead of implicit widening here + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Read/write separation: PASS - control management is an explicit platform-plane mutation with confirmation, audit, and focused tests; blocked execution paths remain non-mutating except for audit logging. +- RBAC-UX: PASS - platform management stays on `/system`; tenant/admin execution surfaces stay on `/admin/t/{tenant}`; cross-plane access remains 404; entitled-but-paused users get explicit control feedback while membership and capability failures keep 404/403 semantics. +- Workspace isolation / tenant isolation: PASS - workspace-targeted controls apply only within the chosen workspace; tenant surfaces still resolve tenant/workspace entitlement before control-state disclosure. +- Run observability / Ops-UX: PASS - allowed starts reuse existing `OperationRun` paths; blocked starts create no run and no new lifecycle dialect; later control activation does not retroactively mutate already accepted runs; shared start/result helpers remain authoritative. +- Shared path reuse / `XCUT-001`: PASS - the design extends existing UI enforcement, provider-start gating, audit logging, and operation start UX instead of introducing page-local flags. +- Provider boundary / `PROV-001`: PASS - control language stays provider-neutral while restore execution remains provider-owned. +- Proportionality / `PROP-001` and `ABSTR-001`: PASS - the only new structure is justified by two current-release controls and three existing enforcement surfaces; no experimentation platform or generalized remote-config system is planned. +- Persisted truth / `PERSIST-001`: PASS - active control activations represent independent runtime-safety truth with their own scope, reason, expiry, and audit obligations; convenience UI state remains derived. +- Behavioral state / `STATE-001`: PASS - paused/enabled semantics change whether execution may start and therefore justify one bounded effective-state model. +- Filament-native UI / `UI-FIL-001`: PASS - all touched surfaces remain native Filament pages/resources/actions; no custom UI framework is introduced. +- Global search rule: N/A - no new globally searchable resource is added. +- Panel/provider registration: PASS - Filament v5 remains on Livewire v4 and no new panel/provider registration is required; Laravel 12 provider registration stays in `bootstrap/providers.php` if any provider change becomes necessary. +- Test governance / `TEST-GOV-001`: PASS - proof stays in focused unit and feature lanes with no browser or heavy-governance expansion. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for catalog/evaluator/scope precedence/expiry logic; Feature for system control management, runbook enforcement, findings header-action enforcement, restore-execution enforcement, audit logging, and `404`/`403` semantics +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: the business truth is server-side effective-state resolution plus enforcement at existing Filament and service seams. Browser tests would duplicate modal choreography without proving additional runtime safety truth. +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsControls/OperationalControlManagementTest.php tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` +- **Fixture / helper / factory / seed / context cost risks**: add one local factory for active control activations plus platform-user and workspace-scoped setup helpers reused only by operational-control tests; avoid new shared browser or provider-fixture defaults +- **Expensive defaults or shared helper growth introduced?**: no; control fixtures stay opt-in and local to the new test family +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: standard-native-filament and monitoring-state-page relief are sufficient; assert disabled/blocked behavior and no side effects instead of browser-only choreography +- **Closing validation and reviewer handoff**: reviewers should rerun the targeted unit/feature commands, verify the env gate is removed from the in-scope findings action, confirm restore execution is blocked before queue/provider start, confirm blocked-execution audit entries exist for runbook/findings/restore paths, confirm global control changes audit without false workspace ownership, confirm `/system/ops/controls` returns 403 for system users missing `platform.ops.controls.manage`, and confirm non-members still receive 404 while missing capabilities still receive 403 with the existing capability-denied UX rather than paused-state helper text +- **Budget / baseline / trend follow-up**: low-to-moderate increase in focused unit/feature coverage only +- **Review-stop questions**: did implementation add a second control persistence shape, leave the env gate in place, introduce a local blocked-state dialect, or widen into browser/heavy-governance lanes? +- **Escalation path**: `reject-or-split` if the implementation widens into generic feature-flagging or customer-managed controls; `document-in-feature` for small shared-helper extensions that remain local to this slice +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: the planned new model, evaluator, and tests stay local to the first-slice control family; recurring growth beyond the two bounded control keys would require its own follow-up spec + +## Project Structure + +### Documentation (this feature) + +```text +specs/242-operational-controls/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── checklists/ +│ └── requirements.md +├── contracts/ +│ └── operational-controls.contract.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/System/Pages/Ops/ +│ │ ├── Controls.php +│ │ └── Runbooks.php +│ ├── Filament/Resources/FindingResource/Pages/ListFindings.php +│ ├── Filament/Resources/RestoreRunResource.php +│ ├── Models/ +│ │ └── OperationalControlActivation.php +│ ├── Services/Audit/AuditRecorder.php +│ ├── Services/Audit/WorkspaceAuditLogger.php +│ ├── Services/Providers/ProviderOperationStartGate.php +│ ├── Support/Audit/AuditActionId.php +│ ├── Support/Auth/PlatformCapabilities.php +│ └── Support/OperationalControls/ +│ ├── OperationalControlCatalog.php +│ ├── OperationalControlDecision.php +│ └── OperationalControlEvaluator.php +├── database/ +│ ├── factories/ +│ │ └── OperationalControlActivationFactory.php +│ └── migrations/ +│ └── *_create_operational_control_activations_table.php +└── tests/ + ├── Feature/ + │ ├── Findings/OperationalControlFindingsBackfillGateTest.php + │ ├── OperationalControls/ + │ │ ├── NoAdHocOperationalControlBypassTest.php + │ │ └── OperationalControlAuthorizationSemanticsTest.php + │ ├── Restore/OperationalControlRestoreExecutionGateTest.php + │ ├── System/OpsControls/OperationalControlManagementTest.php + │ └── System/OpsRunbooks/OperationalControlRunbookGateTest.php + └── Unit/Support/OperationalControls/ + ├── OperationalControlCatalogTest.php + ├── OperationalControlEvaluatorTest.php + └── OperationalControlScopeResolutionTest.php +``` + +**Structure Decision**: Single Laravel web application. The feature adds one bounded platform-operated model and one small support namespace for operational-control evaluation, then plugs that into existing system and tenant Filament surfaces. + +## Complexity Tracking + +No unapproved constitution violations are required. The only new persistence and abstraction are the justified control-activation record plus evaluator/catalog pair described below. + +## Proportionality Review + +- **Current operator problem**: founders and platform operators need a safe runtime way to pause already-existing risky actions without editing environment variables or relying on inconsistent per-surface logic. +- **Existing structure is insufficient because**: `UiEnforcement` decides RBAC, `ProviderOperationStartGate` decides provider readiness, and env flags decide hidden page-local runtime behavior. None of those alone gives one auditable runtime-safety truth across both system and tenant surfaces. +- **Narrowest correct implementation**: persist only explicit active control activations, derive the enabled state from absence of an activation, evaluate one effective decision through a shared catalog/evaluator, and wire that into the three concrete existing start paths. +- **Ownership cost created**: one new table/model/factory, one small support namespace, one system page, new audit action IDs and capability constants, and focused unit/feature coverage. +- **Alternative intentionally rejected**: keep env/config flags, reuse workspace settings, or build a generalized feature-flag system. Env/config flags are invisible product truth, workspace settings do not cleanly represent one global control truth, and a generic flag platform is far too broad. +- **Release truth**: current-release truth + +## Phase 0 — Research (output: `research.md`) + +See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/research.md` + +Goals: +- Confirm the narrowest persistence shape for runtime-safety truth and explicitly reject env-only or workspace-settings-only alternatives. +- Confirm the smallest shared seam where control evaluation belongs for system runbooks, tenant findings lifecycle backfill, and provider-backed restore execution. +- Define v1 scoping, global-first precedence, expiry, and audit expectations without inventing a generic flag taxonomy. +- Document the v1 decision that break-glass and broad platform capabilities do not bypass an active operational control. + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +See: +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/data-model.md` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/contracts/operational-controls.contract.yaml` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/quickstart.md` + +Design focus: +- Add one platform-operated activation record that can pause a control globally or for one workspace, with optional expiry, auditable reason, global-first precedence, and partial unique indexes that enforce one active global row per control and one active workspace row per control/workspace pair; the write path deletes expired conflicting rows before inserting a new activation, and this table is not used as an archive. +- Add one new system ops controls page that lists the two bounded control keys, their effective state, scope, owner, expiry, change actions, and on-demand audit history links, and uses a staged scope-impact preview before control mutations are confirmed. +- Use `OperationalControlDecision` as the shared control-state presentation primitive for controls, runbooks, findings, and restore surfaces. +- Route `findings.lifecycle.backfill` through the new evaluator in both `ListFindings` and `Runbooks`, removing the existing env gate. +- Route `findings.lifecycle.backfill` through `FindingsLifecycleBackfillRunbookService::start()` so the system runbooks page, tenant findings page, CLI command, and deploy-hook command all honor the same control decision. +- Route `restore.execute` through the same evaluator before provider-backed or non-provider-backed queued restore execution is created. +- Add dedicated audit action IDs and a dedicated platform capability for control management, using `AuditRecorder` for global control changes and blocked system-plane all-tenant attempts, and `WorkspaceAuditLogger` for workspace/tenant-scoped changes and blocked-execution evidence with concrete scope. +- Keep blocked-state messaging on existing shared start/result helpers and avoid custom control-state UI frameworks. + +## Phase 1 — Agent Context Update + +After Phase 1 artifacts are generated, update Copilot context from the plan: + +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot` + +## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`) + +- Add the `operational_control_activations` persistence, model, and local factory for active pause records. +- Introduce the bounded operational-controls support namespace (`OperationalControlCatalog`, `OperationalControlDecision`, `OperationalControlEvaluator`) and keep enabled-state derived from active rows. +- Add the dedicated controls-manage capability and its local grant path in the seeded platform operator setup. +- Add the system-plane controls page and wire it into the existing system ops navigation with staged preview-plus-confirm pause/resume actions, audit logging, and on-demand audit history links. +- Replace the findings env gate with evaluator-driven control checks on the tenant findings header action and the system runbooks start path. +- Integrate the same evaluator into restore execution before any queued execution `OperationRun`, queued execution `RestoreRun`, queue dispatch, or provider-backed execution starts. +- Add focused unit and feature tests, plus a guard test that blocks new ad-hoc runtime-control bypasses for in-scope controls and one proving path that activating a control does not rewrite previously accepted runs. + +## Constitution Check (Post-Design) + +Re-check target: PASS. The post-design shape must still use one bounded control catalog, one active-row persistence model, one evaluator, existing auth/start/audit helpers, and no second runtime-control dialect. + +## Implementation Close-out + +- Delivered the bounded operational-controls slice end-to-end: one `operational_control_activations` truth model, one catalog/evaluator/decision support path, a new `/system/ops/controls` management page, findings lifecycle enforcement through `FindingsLifecycleBackfillRunbookService::start()`, and restore execution blocking before any queued execution `OperationRun`, queued execution `RestoreRun`, job dispatch, or provider-backed start. +- Runtime cleanup landed with the in-scope findings env gate removed from `config/tenantpilot.php`, a source-scanning guard against ad-hoc bypasses, and workspace-isolation proof showing a workspace-scoped pause blocks only the targeted workspace while a second workspace remains unaffected. +- Validation passed on the narrow feature lane: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php tests/Feature/System/OpsControls/OperationalControlManagementTest.php tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` with `20 passed (253 assertions)`. +- Formatting passed with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. +- Manual smoke passed in the integrated browser: the staged pause/resume flow on `/system/ops/controls` for `Findings lifecycle backfill` rendered scope-impact previews, applied the global pause, and returned to `Enabled` inside the SC-001 budget after bringing the local database up to date. diff --git a/specs/242-operational-controls/quickstart.md b/specs/242-operational-controls/quickstart.md new file mode 100644 index 00000000..bcfbdd53 --- /dev/null +++ b/specs/242-operational-controls/quickstart.md @@ -0,0 +1,50 @@ +# Quickstart — Operational Controls + +## Prereqs + +- Docker running +- Laravel Sail dependencies installed +- A platform user able to access `/system` +- Existing workspace, tenant, findings, restore-run, and operation-run factories available for tests + +## Run locally + +- Start containers: `cd apps/platform && ./vendor/bin/sail up -d` +- Run migrations for the new activation table: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan migrate --no-interaction` +- Refresh the seeded local platform operator after the new capability is added: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan db:seed --class=PlatformUserSeeder --no-interaction` +- Run targeted tests after implementation: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php tests/Feature/System/OpsControls/OperationalControlManagementTest.php tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` + - Full narrow suite: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php tests/Feature/System/OpsControls/OperationalControlManagementTest.php tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` +- Format after implementation: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## Manual smoke after implementation + +1. Sign in to `/system` as a platform operator with `platform.access_system_panel` and the new operational-controls management capability. +2. Sign in as a system user without the operational-controls management capability and verify `/system/ops/controls` returns 403 with the existing capability-denied UX rather than paused-state helper text. +3. Open `/system/ops/controls`, begin pausing `Findings lifecycle backfill` globally, verify the modal shows scope-impact preview before confirmation, then confirm and verify the control card exposes on-demand change history or an audit link for that change. +4. Open `/system/ops/runbooks`, choose the all-tenants findings-lifecycle path, and verify the runbook path shows an explicit paused-state message and does not start a run. +5. Open `/admin/t/{tenant}/findings` as an entitled tenant user and verify `Backfill findings lifecycle` is still presented truthfully for entitled users but blocked with the same control reason. +6. Invoke `tenantpilot:findings:backfill-lifecycle --tenant={tenant_id}` and verify the shared findings lifecycle service blocks the start with the same control state. +7. Pause `Restore execution` for one workspace only, then verify an entitled tenant in that workspace cannot start restore execution, no queued execution `RestoreRun` or `OperationRun` is created by the blocked start path, and a blocked-execution audit entry is recorded. +8. Verify an entitled tenant in a different workspace remains unaffected for `Restore execution`. +9. Resume both controls and confirm the normal start paths return without a deploy or env edit. +10. Verify audit entries exist for global pause/resume, workspace-targeted pause/resume, and blocked execution on the runbook, findings, and restore paths; confirm the blocked all-tenants runbook attempt is recorded as a platform-plane event with requested-scope metadata. +11. Time one pause or resume flow on `/system/ops/controls` and confirm the staged preview-plus-confirm path completes in under 1 minute. + +## Notes + +- Filament v5 remains on Livewire v4.0+ in this repo; the slice stays on native Filament pages/resources/actions. +- No panel provider registration changes are planned; Laravel 12 provider registration remains in `bootstrap/providers.php` if any provider change becomes necessary. +- No global-search behavior changes are involved because the slice adds no new searchable resource. +- The state-changing control actions are destructive-like and must use `->requiresConfirmation()`. +- Global pauses win over workspace-specific pauses in v1; no narrower workspace record re-enables a globally paused control. +- No new frontend asset pipeline is expected; no new `filament:assets` deploy step is needed unless implementation adds registered assets later. + +## Implementation Close-out + +- Guardrail result: `tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` passed after narrowing the forbidden config check to the retired `tenantpilot.allow_admin_maintenance_actions` path instead of unrelated `tenantpilot` reads. +- Latest targeted validation passed: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php tests/Feature/System/OpsControls/OperationalControlManagementTest.php tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` with `20 passed (253 assertions)`. +- Shared-helper note: `OperationalControlDecision` now exposes workspace-aware presentation helpers, the findings path routes through `FindingsLifecycleBackfillRunbookService::start()`, and restore execution is blocked before any queued execution `OperationRun`, queued execution `RestoreRun`, queue dispatch, or provider call. +- Manual smoke status: passed in the integrated browser on `http://localhost/system/ops/controls` after seeding the local platform operator and running the pending operational-controls migration; the staged global pause and resume flow for `Findings lifecycle backfill` completed successfully within the SC-001 budget. \ No newline at end of file diff --git a/specs/242-operational-controls/research.md b/specs/242-operational-controls/research.md new file mode 100644 index 00000000..3b0c0eb3 --- /dev/null +++ b/specs/242-operational-controls/research.md @@ -0,0 +1,133 @@ +# Research — Operational Controls + +**Date**: 2026-04-26 +**Spec**: [spec.md](spec.md) + +This document captures design decisions and supporting rationale for the first operational-controls slice. All decisions are grounded in current repository truth and the TenantPilot Constitution. + +## Decision 1 — Persist only active pause records, derive the enabled state, and let global pauses win + +**Decision**: Store only explicit active control activations that pause a control. Do not persist `enabled` rows or a broader multi-state lifecycle. The effective `enabled` state is derived from the absence of an active matching activation, and a matching global pause wins over a narrower workspace pause in v1. + +**Rationale**: +- The operator problem is safe runtime pause control, not a new workflow state machine. +- Constitution `PERSIST-001` and `STATE-001` favor the smallest persisted truth that changes behavior. +- Deriving `enabled` avoids importing a second layer of default-state maintenance. +- Global-first precedence is the safest bounded rule because a platform-wide incident pause must not be narrowed by a workspace-specific row in this first slice. + +**Evidence**: +- The current code gap is an env-gated yes/no maintenance switch in `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`. +- The first slice only needs to answer one question at execution time: may this action start right now for this scope? +- The first slice does not support workspace-specific allow overrides, so no narrower row should reopen a globally paused control. + +**Alternatives considered**: +- Persist both `enabled` and `paused` rows. + - Rejected: unnecessary state duplication; absence of an active pause already means enabled. +- Add a larger status family such as draft, scheduled, paused, forced, emergency. + - Rejected: too broad for current-release truth. + +## Decision 2 — Use one platform-operated activation table instead of env flags or workspace settings + +**Decision**: Introduce one platform-operated `operational_control_activations` table that can represent either a global pause or a workspace-scoped pause. Do not split truth across env flags, platform config, and workspace settings. + +**Rationale**: +- The spec requires one auditable control truth across system and tenant surfaces. +- Existing workspace settings infrastructure is workspace-only and cannot represent one global platform-wide safety state cleanly. +- Env flags are invisible product truth and require deploy-time coordination. + +**Evidence**: +- Existing workspace settings writer only manages workspace-scoped settings in `apps/platform/app/Services/Settings/SettingsWriter.php`. +- The current env gate lives in `apps/platform/config/tenantpilot.php` and is consumed directly in `ListFindings`. + +**Alternatives considered**: +- Reuse workspace settings for workspace overrides and keep a global env flag. + - Rejected: split truth, inconsistent audit semantics, and no single effective-state evaluator. +- Use env flags only. + - Rejected: not operator-visible or auditable in-product. + +## Decision 3 — Evaluate controls at the start seam, not only in UI visibility + +**Decision**: Integrate control evaluation at the concrete start seams that already own execution decisions: `FindingsLifecycleBackfillRunbookService::start()` for all findings lifecycle backfill callers, and queued restore execution before `OperationRun` or provider dispatch begins. + +**Rationale**: +- UI-only hiding would fail the safety requirement because direct requests or stale page state could still start execution. +- The repo already has clear start seams where action or service logic decides whether a run begins. +- This keeps blocked-state truth server-side and shared. + +**Evidence**: +- Findings lifecycle backfill starts in `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php` and is called from the system runbooks page, tenant findings page, `tenantpilot:findings:backfill-lifecycle`, and `tenantpilot:run-deploy-runbooks`. +- Restore execution starts in `apps/platform/app/Filament/Resources/RestoreRunResource.php` and already routes provider-backed starts through `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`. + +**Alternatives considered**: +- Hide or disable actions in UI only. + - Rejected: violates the server-side enforcement requirement. + +## Decision 4 — Add one system ops controls page instead of surface-local toggles + +**Decision**: Manage the first-slice controls from one dedicated system ops page under `/system/ops/controls`. Do not add per-page toggles or bury control changes inside each affected surface. The page shows effective-state summaries by default and exposes change history through on-demand audit links instead of creating a second history surface. + +**Rationale**: +- Operators need one place to make the runtime-safety decision itself. +- Constitution `DECIDE-001` and the spec’s decision-role table require a primary decision surface for control management. +- A shared control center prevents drift between runbooks, findings, and restore surfaces. + +**Evidence**: +- The repo already groups ops surfaces under `apps/platform/app/Filament/System/Pages/Ops/`. +- Existing runbooks and run viewers are already system-plane ops surfaces, so a sibling controls page fits the current information architecture. + +**Alternatives considered**: +- Add a toggle to the runbooks page only. + - Rejected: restore execution is not owned by that page and the control decision would stay fragmented. + +## Decision 5 — Break-glass does not bypass operational controls in v1 + +**Decision**: Break-glass sessions do not automatically bypass active operational controls in the first slice. + +**Rationale**: +- Operational controls are introduced as runtime-safety truth, not as optional UI friction. +- An implicit bypass would make incident behavior ambiguous and weaken auditability. +- The first slice stays safer by forcing an explicit resume action before execution. + +**Evidence**: +- The system runbook page already has break-glass-aware reason requirements via `BreakGlassSession`, but operational controls are a distinct safety layer. + +**Alternatives considered**: +- Let break-glass ignore controls. + - Rejected: too risky for v1 and not required by current operator pain. + +## Decision 6 — Reuse existing audit and start-result helpers, but keep global audits platform-scoped + +**Decision**: Keep workspace-targeted changes and blocked execution evidence with concrete workspace or tenant context on `WorkspaceAuditLogger` plus `AuditActionId`, but record global control changes and blocked system-plane all-tenant attempts through `AuditRecorder` directly so they stay platform-plane events without false workspace ownership. Include requested-scope metadata on those platform-plane blocked attempts. Keep blocked/allowed execution messaging on the existing operation/provider start-result helpers. + +**Rationale**: +- Constitution `XCUT-001` requires reuse of existing shared interaction paths. +- The repo already has shared primitives for queued toasts, dedupe messaging, and audit summaries. +- This avoids a second language for blocked execution. +- `WorkspaceAuditLogger` requires a `Workspace`, while `AuditRecorder` already supports null workspace and null tenant for truthful system-plane events. + +**Evidence**: +- Audit logging lives in `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`. +- Global system-plane audit support lives in `apps/platform/app/Services/Audit/AuditRecorder.php`. +- Canonical audit IDs live in `apps/platform/app/Support/Audit/AuditActionId.php`. +- Provider-backed start messaging already routes through `ProviderOperationStartResultPresenter` and `OperationUxPresenter`. + +**Alternatives considered**: +- Emit page-local notifications and free-form audit action strings. + - Rejected: immediate drift risk and weaker reviewability. + +## Decision 7 — Proof stays in Unit + Feature lanes only + +**Decision**: Keep proof in focused unit and feature tests. Do not introduce browser tests or heavy-governance coverage for this first slice. + +**Rationale**: +- The business truth is effective-state evaluation, audit recording, and blocked/no-side-effect execution. +- Browser coverage would mostly duplicate existing Filament modal behavior. +- Constitution `TEST-GOV-001` requires the narrowest proving lane mix. + +**Evidence**: +- Existing system runbooks and restore features already have focused feature coverage patterns in the repo. +- The new logic is server-side and deterministic. + +**Alternatives considered**: +- Add browser smoke for pause/resume flows. + - Rejected: not needed to prove the core runtime-safety semantics of this slice. \ No newline at end of file diff --git a/specs/242-operational-controls/spec.md b/specs/242-operational-controls/spec.md new file mode 100644 index 00000000..ee2dc051 --- /dev/null +++ b/specs/242-operational-controls/spec.md @@ -0,0 +1,290 @@ +# Feature Specification: Operational Controls + +**Feature Branch**: `242-operational-controls` +**Created**: 2026-04-26 +**Status**: Draft +**Input**: User description: "Operational Controls & Feature Flags: create a narrow first slice that replaces ad-hoc environment-gated risky admin maintenance actions with a central audited operational control path. Reuse the existing system panel, platform capabilities, audit logging, and server-side action/service enforcement to let operators pause or disable selected high-risk features with explicit disabled-state messaging that is distinct from authorization failure, without turning this into a generic experimentation or entitlement platform." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot already has risky actions that can only be paused through local environment flags, deploy-time changes, or ad-hoc code decisions instead of one product-owned operational control contract. +- **Today's failure**: During an incident or rollout concern, operators cannot centrally pause in-scope risky flows such as findings lifecycle backfill or restore execution with consistent UX, auditable ownership, and server-side enforcement. The current `allow_admin_maintenance_actions` environment gate makes one tenant admin action appear or disappear outside the product, while similar runbook and provider-backed actions have no shared pause contract. +- **User-visible improvement**: Platform operators can pause selected high-risk actions from a system-plane control surface, and affected admin/operator surfaces show explicit paused-state messaging instead of disappearing silently, looking unauthorized, or requiring a deployment to change runtime behavior. +- **Smallest enterprise-capable version**: Introduce one bounded operational-control contract for two concrete first-slice controls - `findings.lifecycle.backfill` and `restore.execute` - with global and workspace-targeted activation, one system-plane management surface with on-demand audit history, and server-side enforcement on the existing runbook, findings-maintenance, and restore-execution start paths. +- **Explicit non-goals**: No A/B testing, no customer-managed feature flags, no generic remote-config platform, no entitlement/billing replacement, no tenant-scoped self-service flags, no broad maintenance-mode replacement for the whole app, and no speculative control catalog for every future feature. +- **Permanent complexity imported**: One operational-control catalog, one persisted control-activation record family with explicit scope and reason, one evaluation service at action/service boundaries, a small amount of shared paused-state copy/presentation, audit action IDs, and focused unit/feature/guard coverage. +- **Why now**: The repo already exposes the control gap in live code through `config('tenantpilot.allow_admin_maintenance_actions')`, while live pilots and founder-operated support increase the need for safe runtime pause controls before more onboarding, support, AI, and customer-facing workflows land. +- **Why not local**: A local config flag or page-specific guard cannot safely cover both system-plane runbooks and tenant-plane provider-backed execution, cannot produce one auditable truth, and teaches parallel runtime-control semantics across surfaces. +- **Approval class**: Core Enterprise +- **Red flags triggered**: New meta-infrastructure, foundation-sounding scope. Defense: the slice is intentionally limited to two real existing high-risk controls, one management surface, and one shared evaluator instead of a universal experimentation or entitlement platform. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: platform, workspace, tenant +- **Primary Routes**: + - New system-plane operational controls surface under `/system/ops/controls` + - Existing system runbook launcher at `/system/ops/runbooks` + - Existing tenant findings register at `/admin/t/{tenant}/findings` + - Existing restore execution start flow in the restore-run create surface under `/admin/t/{tenant}/restore-runs/create`; existing restore record-view actions remain unchanged and out of scope for this slice +- **Data Ownership**: + - Control definitions remain platform-owned catalog truth in code, limited to the first-slice keys `findings.lifecycle.backfill` and `restore.execute` + - Control activations are platform-operated runtime-safety records; workspace-targeted activations reference a workspace, while global activations apply to all workspaces without embedding tenant-owned data + - No tenant-owned control records are introduced in this slice; tenant/admin surfaces consume effective control decisions only + - Audit history stays on existing `AuditLog` truth with stable action IDs for control activation, update, removal, and blocked execution; global control changes are platform-plane audit events with no false workspace or tenant owner, workspace-targeted changes and blocked starts with concrete workspace/tenant context retain truthful workspace/tenant audit scope, and blocked system-plane attempts without a concrete workspace/tenant resolve to platform-plane audit events with requested-scope metadata +- **RBAC**: + - Management happens only in the platform `/system` plane and requires `PlatformCapabilities::ACCESS_SYSTEM_PANEL` plus a dedicated operational-controls management capability + - Existing tenant/admin capabilities remain authoritative for the underlying in-scope actions (`findings.lifecycle.backfill`, `restore.execute`) + - Non-members or non-entitled users still receive 404 on tenant/workspace boundaries; members lacking the underlying capability still receive 403 and continue to follow the existing surface-specific capability-denied UX with no paused-state helper text; entitled users blocked only by an active operational control receive explicit paused-state feedback that is distinct from authorization failure + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: N/A - this slice does not add a new canonical collection route in `/admin`; it affects existing tenant and system execution surfaces +- **Explicit entitlement checks preventing cross-tenant leakage**: Control evaluation never weakens existing tenant/workspace membership checks. Tenant-plane surfaces resolve tenant entitlement first, then evaluate the effective control state only for already-entitled users. + +## 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 +- **Interaction class(es)**: header actions, runbook launch actions, provider-backed start gating, status messaging, audit prose +- **Systems touched**: system runbooks, tenant findings maintenance action, `FindingsLifecycleBackfillRunbookService::start()` plus its CLI and deploy-hook callers, restore execution start path, existing audit logging, operation start UX, existing capability enforcement helpers +- **Existing pattern(s) to extend**: `UiEnforcement`, `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, `OperationUxPresenter`, `AuditRecorder`, `WorkspaceAuditLogger`, and existing system/tenant operation-link helpers +- **Shared contract / presenter / builder / renderer to reuse**: one new operational-control evaluator is allowed, but it must sit beside existing capability and provider-start gates instead of creating new page-local flag logic. Existing audit and start-result presenters remain authoritative for labels, reasons, action/result messaging, and truthful system-plane versus workspace-plane audit ownership. +- **Why the existing shared path is sufficient or insufficient**: existing shared paths already solve authorization, audit recording, and start-result UX. They are insufficient because none of them currently carry one central runtime-safety decision that can pause an action consistently across tenant and system surfaces. +- **Allowed deviation and why**: none. The first slice must remove the ad-hoc environment flag for in-scope maintenance actions rather than adding another exception path. +- **Consistency impact**: control labels, paused-state wording, reason display, audit action IDs, and allow/block semantics must match across the controls page, runbooks page, findings list header action, restore execution start flow, and any related notifications. +- **Review focus**: reviewers must block new direct `config(...)` or env-based runtime gates for in-scope operational controls and verify that findings lifecycle backfill routes through `FindingsLifecycleBackfillRunbookService::start()` plus the shared evaluator for UI, CLI, and deploy-hook callers, while restore continues through its existing start seam and presenters. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: yes +- **Shared OperationRun UX contract/layer reused**: `OperationRunService`, `OperationUxPresenter`, `OpsUxBrowserEvents`, `ProviderOperationStartResultPresenter`, `OperationRunLinks`, and `SystemOperationRunLinks` +- **Delegated start/completion UX behaviors**: when an action is allowed, queued toast, `View run` link, run-enqueued browser event, dedupe-or-already-queued messaging, and tenant/workspace-safe URL resolution remain delegated to the existing shared paths. When a control blocks execution, the surface reuses the shared start-result or notification path for one explicit paused-state message and does not invent a second blocked-run dialect. +- **Local surface-owned behavior that remains**: initiation inputs, confirmation text, and scope selection remain local to the runbook page, findings list page, or restore workflow. Local code does not decide operational-control truth. +- **Queued DB-notification policy**: unchanged. This slice does not introduce new queued DB notifications for paused or allowed starts. +- **Terminal notification path**: unchanged central lifecycle mechanism for runs that do start. +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: yes +- **Boundary classification**: mixed +- **Seams affected**: platform-core operational-control vocabulary, restore execution provider-start boundary, shared operator messaging for blocked execution +- **Neutral platform terms preserved or introduced**: operational control, effective state, paused, scope, reason, expiry, owner, override +- **Provider-specific semantics retained and why**: `restore.execute` remains a provider-owned operation and keeps its current Microsoft-only execution path and dry-run safeguards. The control system only governs whether that path may start; it does not rename or generalize restore semantics. +- **Why this does not deepen provider coupling accidentally**: the control catalog is platform-owned and names operation keys that already exist. Provider-specific behavior stays inside the existing restore-start path and provider registry. +- **Follow-up path**: none for the first slice; broader catalog growth remains a follow-up decision, not an implied obligation of this spec + +## 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 | +|---|---|---|---|---|---|---| +| System ops controls surface | yes | Native Filament + shared primitives | status messaging, audit-backed actions, control state summary | page, card/action state, modal | no | New system-plane control center for a bounded first-slice catalog | +| System runbooks launcher | yes | Native Filament + shared runbook/start UX | run start messaging, confirmation flow, blocked-state messaging | page, action, preflight state | no | Existing page gains operational-control awareness only | +| Tenant findings list header action | yes | Native Filament + existing action-surface primitives | header actions, run start messaging | table, header action, modal | no | Existing maintenance action loses env-flag gating and becomes control-aware | +| Restore run create/start workflow | yes | Native Filament resource + shared provider start gate | provider-backed start result, disabled-state copy | form/wizard, create action, start-result state | no | Existing risky tenant workflow gains central pause semantics without new tenant-side control UI | + +## 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 | +|---|---|---|---|---|---|---|---| +| System ops controls surface | Primary Decision Surface | Decide whether one risky feature should stay available, be paused, or be scoped down during an incident or rollout | control name, effective scope, paused/enabled state, reason, owner, expiry | change history, affected actions, audit links | Primary because this is the system-plane place where operators make the runtime-safety decision itself | Follows incident-control and rollout workflow, not feature storage structure | Replaces deploy-time or env-level toggling with one visible operational decision point | +| System runbooks launcher | Secondary Context Surface | Decide whether to preflight or start a runbook once the control state is already known | current control state, preflight, confirmation requirements, next safe action | existing run detail after start, control reason history | Secondary because the main decision here is execution of a specific runbook, not control management | Keeps runbook workflow intact while surfacing control truth inline | Avoids surprise 403s or silent disappearance when the runbook is paused | +| Tenant findings list header action | Secondary Context Surface | Decide whether to start tenant findings backfill | header action availability, paused-state message, tenant scope | run detail only after allowed start | Secondary because the list remains the primary findings workflow; runtime control is supporting context | Preserves list-first findings work while exposing truthful blocked state | Removes hidden env-driven behavior drift on one tenant surface | +| Restore run create/start workflow | Secondary Context Surface | Decide whether a restore may proceed now | effective control state, restore-specific next action, existing safety messaging | preview, diff, and run detail when allowed | Secondary because restore creation remains the main operator decision and control state is a gating constraint | Keeps restore workflow focused while making pause state explicit before execution | Prevents risky restore attempts from failing late or ambiguously | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| System ops controls surface | Utility / System | Operational safety control center | Pause, resume, or scope a control | Explicit card action or modal from the page itself | forbidden | Secondary details live in card reveals or modal details only | State-changing control actions are confirmation-protected and stay on the control card/modal | `/system/ops/controls` | `/system/ops/controls` | Global, all-workspaces, or workspace-targeted scope | Operational controls / Operational control | Effective state, reason, scope, and expiry | none | +| System runbooks launcher | Monitoring / Queue / Workbench | System runbook launcher | Preflight or start a runbook | In-page action modal then `View run` after success | forbidden | Related navigation stays secondary in toast actions or page summaries | Dangerous execution remains in the existing `Run...` action with confirmation | `/system/ops/runbooks` | `/system/ops/runbooks` | Current control state and selected tenant/all-tenant scope | Runbooks / Runbook | Whether execution is allowed right now and why | none | +| Tenant findings list header action | List / Table / Bulk | List-first resource | Open findings or start lifecycle backfill | Existing table inspection remains primary; header action is explicit secondary execution | required | Secondary execution stays in the header only | Backfill remains confirmation-protected in the header action | `/admin/t/{tenant}/findings` | existing findings detail route | Tenant scope and effective control state for entitled users | Findings / Finding | Findings list truth plus explicit maintenance availability | none | +| Restore run create/start workflow | Wizard / Flow | Create and launch flow | Continue restore setup or stop because execution is paused | Existing create form/wizard remains primary | forbidden | Secondary navigation lives in helper links and post-start run links | Existing restore execution remains inside the create/start flow with its current safety steps | `/admin/t/{tenant}/restore-runs` | `/admin/t/{tenant}/restore-runs/{record}` | Tenant scope, preview/dry-run state, and effective control state | Restore runs / Restore run | Whether restore execution may proceed, with scope and reason | 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| System ops controls surface | Platform operator / break-glass operator | Decide whether risky runtime actions remain enabled, paused globally, or paused for one workspace | Control center | Is this risky action allowed right now, for whom, and why? | control label, effective state, scope, reason, owner, expiry | audit history, exact affected surfaces, internal notes | runtime safety state, scope, expiry | TenantPilot only | Pause control, Resume control, Change scope | Pausing or resuming a control | +| System runbooks launcher | Platform operator | Decide whether to preflight/start the runbook or respect a pause control | Workbench | Can I run this runbook now? | current control state, runbook scope, preflight result, confirmation requirements | latest run detail and audit history after navigation | runtime safety state, execution readiness, preflight result | TenantPilot only when blocked; Microsoft tenant or tenant data changes only when allowed and executed | Preflight, Run | Run | +| Tenant findings list header action | Tenant manager / owner | Decide whether findings lifecycle backfill may start for the current tenant | List-first resource + secondary header action | Is lifecycle backfill available for this tenant right now? | explicit paused/enabled state for entitled users, tenant scope, action label | run detail only if execution is allowed and started | runtime safety state, execution readiness | TenantPilot only when blocked; tenant data mutation if execution is allowed | Backfill findings lifecycle | Backfill findings lifecycle | +| Restore run create/start workflow | Tenant manager / owner | Decide whether restore execution may proceed after existing safety checks | Guided creation flow | Can this restore execute now, or is the operation paused? | control state, restore scope, dry-run/preview state, next action | preview diff, post-start run detail, raw diagnostics after navigation | runtime safety state, lifecycle, restore safety/preflight state | TenantPilot only when blocked; Microsoft tenant when execution is allowed | Create restore run, Continue preview | Execute restore | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: yes +- **New persisted entity/table/artifact?**: yes +- **New abstraction?**: yes +- **New enum/state/reason family?**: yes, one bounded enabled/paused effective-state axis for the control contract +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Operators cannot safely pause already-existing risky actions without deploy-time flags, inconsistent UX, or page-local code branches. +- **Existing structure is insufficient because**: authorization and provider-start gates decide who may act, not whether the product should temporarily allow the action at all. The current env flag is invisible product truth and cannot cover system-plane plus tenant-plane paths consistently. +- **Narrowest correct implementation**: a code-owned two-control catalog plus persisted control activations, one evaluator, one management surface, and two concrete enforcement families (`findings.lifecycle.backfill`, `restore.execute`). +- **Ownership cost**: new runtime-safety records, audit action IDs, shared paused-state copy, evaluator tests, and guard coverage that blocks new ad-hoc runtime gates. +- **Alternative intentionally rejected**: keep using env flags, rely on full Laravel maintenance mode, or build a generic customer-facing feature-flag system. Env flags are too hidden, maintenance mode is too broad, and a generic flag platform is too large. +- **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**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: the feature introduces one shared evaluator plus a small number of concrete UI/service enforcement points. Unit tests prove effective-state resolution, scope precedence, expiry, and block reasons. Feature tests prove system-plane management, tenant-plane and system-plane blocked execution, audit logging, and unchanged 404/403 semantics without browser-specific behavior. +- **New or expanded test families**: focused operational-controls unit coverage, system-page management tests, findings-maintenance gate tests, restore-execution gate tests, and one guard test blocking new ad-hoc config gates for in-scope controls +- **Fixture / helper cost impact**: moderate. Tests reuse existing platform users, workspaces, tenants, OperationRun, restore-run, and findings fixtures. No new browser harness, provider emulator, or heavy governance suite is required. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: standard-native-filament, monitoring-state-page +- **Standard-native relief or required special coverage**: ordinary Filament feature coverage is sufficient for the controls page and the affected admin/system surfaces, plus explicit server-side assertions that blocked actions create no run or provider execution side effect, all-tenant blocked runbook attempts audit truthfully, and later control activation does not rewrite already accepted runs. +- **Reviewer handoff**: confirm that the env-gated findings action is now evaluator-driven, restore execution is blocked before queue/provider start, entitled-but-paused users see explicit operational-control messaging, non-entitled users still get 404 or 403 as appropriate, and audit entries record scope/reason/actor for control changes. +- **Budget / baseline / trend impact**: low-to-moderate increase in narrow unit and feature coverage only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsControls/OperationalControlManagementTest.php tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Pause A Risky Action Centrally (Priority: P1) + +As a platform operator, I can pause one risky control from the system plane so the affected runbook and tenant surfaces stop allowing that action without requiring a deployment. + +**Why this priority**: This is the operator-visible core of the feature and the main incident-response value. + +**Independent Test**: Activate the control for `findings.lifecycle.backfill`, open the system controls surface and the affected runbook/findings surfaces, and verify that the action is visibly paused with one explicit reason and no execution path. + +**Acceptance Scenarios**: + +1. **Given** a platform operator pauses `findings.lifecycle.backfill` globally, **When** an entitled operator opens `/system/ops/runbooks` or an entitled tenant user opens `/admin/t/{tenant}/findings`, **Then** the action remains visible in its normal place but is explicitly blocked with paused-state messaging rather than disappearing or looking unauthorized. +2. **Given** the same control is resumed, **When** the affected surfaces reload, **Then** the normal execution path returns without a deploy or config-file change. + +--- + +### User Story 2 - Block Execution Server-Side Without Masquerading As Auth (Priority: P1) + +As an entitled operator, I want an active control to stop execution before any queued run or provider-backed action starts, while still preserving normal 404 and 403 authorization semantics. + +**Why this priority**: The feature fails if it only hides UI or turns operational controls into fake authorization failures. + +**Independent Test**: Activate controls for `findings.lifecycle.backfill` and `restore.execute`, attempt the affected actions through their normal pages, and assert that no `OperationRun` or provider-backed execution starts while entitlement and capability semantics remain unchanged. + +**Acceptance Scenarios**: + +1. **Given** an entitled tenant user has the underlying capability but `restore.execute` is paused for their workspace, **When** they attempt to start restore execution, **Then** the system returns explicit operational-control feedback, creates no new execution run, and makes no outbound provider call. +2. **Given** a user lacks workspace or tenant entitlement, **When** they attempt the same affected action, **Then** the system still responds as not found instead of revealing control-state details. +3. **Given** a user is entitled to the scope but lacks the underlying capability, **When** they attempt the affected action, **Then** the system still returns 403 rather than blaming the operational control. + +--- + +### User Story 3 - Scope A Pause To One Workspace (Priority: P2) + +As a platform operator, I can pause a risky control for one workspace without affecting unrelated workspaces, so incidents or staged rollouts stay bounded. + +**Why this priority**: Workspace scoping is the smallest enterprise-capable version beyond a purely global kill switch and makes the feature reusable for future rollout control. + +**Independent Test**: Create two workspaces, activate a workspace-scoped pause for one of them, and confirm that blocked behavior applies only to the targeted workspace while the other workspace continues normally. + +**Acceptance Scenarios**: + +1. **Given** `restore.execute` is paused for Workspace A only, **When** entitled users in Workspace A and Workspace B attempt restore execution, **Then** Workspace A is blocked with explicit paused-state messaging and Workspace B continues normally. +2. **Given** a workspace-scoped pause expires or is removed, **When** the targeted workspace retries the action, **Then** the action becomes available again without changing any unrelated workspace state. + +### Edge Cases + +- A workspace-scoped activation and a global activation may both exist for the same control; v1 precedence is global-first, and a matching global pause always wins. +- A control may expire while an operator is on the page; stale page state must not start a blocked action after expiry or removal. +- Break-glass platform access does not automatically bypass an operational control unless the spec explicitly authorizes that path in a later slice. +- An action may already be queued before a control is activated; the control governs new starts only and must not silently rewrite historical runs. +- Tenant/admin users who are not entitled to the workspace or tenant must not learn that a control exists for the hidden scope. +- The first slice must retire the in-scope env flag rather than leaving both the env gate and the control evaluator active in parallel. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature introduces no new Graph endpoint family, but it changes the start boundary for existing queued/provider-backed actions. For in-scope controls, the spec requires server-side enforcement before `findings.lifecycle.backfill` or `restore.execute` start, preserves existing confirmation/audit patterns, and keeps long-running work observable through the current `OperationRun` paths whenever execution is allowed. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces a new runtime-safety truth because the current product already needs it now: operators must pause risky actions without deploy-time env changes. The shape stays narrow: a two-key catalog, persisted activations, and one evaluator. It does not become a generalized experimentation, entitlement, or customer flag platform. + +**Constitution alignment (XCUT-001):** The slice is cross-cutting across header actions, runbook starts, provider-backed start gates, and audit messaging. It reuses `UiEnforcement`, `ProviderOperationStartGate`, existing OperationRun UX presenters, and `WorkspaceAuditLogger` rather than introducing local blocked-state dialects. + +**Constitution alignment (PROV-001):** `restore.execute` remains provider-owned, while operational-control vocabulary remains platform-core. The spec keeps provider specifics inside the existing restore path and uses neutral control language for scope, reason, and effective state. + +**Constitution alignment (TEST-GOV-001):** Proof stays in narrow unit and feature coverage. No browser or heavy-governance family is justified. Reviewer handoff must explicitly verify lane fit, unchanged 404/403 semantics, and no hidden provider-side effects on blocked paths. + +**Constitution alignment (OPS-UX):** For starts that are still allowed, the default Ops-UX 3-surface contract remains unchanged. `OperationRun.status` and `OperationRun.outcome` transitions remain service-owned. Paused starts create no `OperationRun`, no queued DB notification, and no new summary-count semantics. + +**Constitution alignment (OPS-UX-START-001):** The feature includes the `OperationRun UX Impact` section and reuses the shared start UX paths. Local surfaces remain responsible only for initiation inputs and page-local confirmation text. Blocked-state feedback is delivered through existing result/notification helpers instead of page-local composition. + +**Constitution alignment (RBAC-UX):** This slice spans the platform `/system` plane for control management and the tenant/admin `/admin` plane for affected execution surfaces. Cross-plane access remains 404. Non-members or non-entitled users receive 404. Members lacking the underlying capability receive 403 and keep the existing surface-specific capability-denied UX rather than paused-state helper text. Entitled users blocked only by an active control receive explicit operational-control feedback distinct from authorization failure. All mutating management actions require a staged safety flow with scope-impact preview, server-side capability checks, and confirmation. Break-glass does not bypass an active control in v1. + +**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. + +**Constitution alignment (BADGE-001):** If paused/enabled state is rendered as a badge or status chip, it must use centralized badge rendering or one shared control-state presentation path, not page-local color decisions. + +**Constitution alignment (UI-FIL-001):** The controls page, runbooks page, findings page, and restore flow remain native Filament surfaces using existing action, section, infolist, and notification primitives. No local status card framework or custom blocked-state component library is introduced. + +**Constitution alignment (UI-NAMING-001):** Primary operator-facing labels use stable verbs and nouns: `Pause control`, `Resume control`, `Operational controls`, `Backfill findings lifecycle`, and `Restore execution`. `Restore execution` is the control and status label, while `Execute restore` is the gated action label. Route-entry labels such as `New restore run` and `Create restore run` refer only to the ungated draft/setup flow. The same vocabulary must be preserved across buttons, modal titles, notifications, and audit prose. + +**Constitution alignment (DECIDE-001):** The new system controls surface is the only new primary decision surface. Runbooks, findings, and restore remain secondary execution contexts that surface control truth inline instead of becoming separate troubleshooting flows. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The controls page acts as a bounded control center with explicit action buttons and no competing inspect model. Existing runbooks, findings, and restore surfaces preserve their current primary inspect/open paths and action hierarchies while gaining one truthful blocked-state branch. + +**Constitution alignment (ACTSURF-001 - action hierarchy):** Control management actions remain separated from navigation and diagnostics. The controls page owns pause/resume management. Runbooks, findings, and restore keep execution actions local but do not own control truth. + +**Constitution alignment (OPSURF-001):** Default-visible content stays operator-first: whether the action is allowed, for which scope, and why. Raw internal control records or configuration internals stay secondary. Each affected execution action must state its mutation scope before execution when allowed, and blocked paths must state that no tenant/provider mutation will occur. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The spec allows one new evaluator because existing direct domain-to-UI mapping cannot express runtime-safety state consistently across system and tenant surfaces. No second presenter taxonomy or explanation framework is added beyond the minimum blocked-state copy path. + +**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied on all touched Filament surfaces. Each affected surface keeps one primary inspect/open model, no redundant `View` action is added, no empty action groups are introduced, and state-changing control actions require confirmation. + +**Constitution alignment (UX-001 - Layout & Information Architecture):** The new controls page uses native Filament sections/cards for control summaries and action modals. Existing runbooks, findings, and restore pages keep their established layout patterns. Any blocked-state summary remains within the current page structure and does not add ad-hoc full-page exception layouts. + +### Functional Requirements + +- **FR-001**: System MUST define a central operational-control catalog for the first-slice keys `findings.lifecycle.backfill` and `restore.execute`. +- **FR-002**: Platform operators MUST be able to activate, update, and remove a control for all workspaces or one specific workspace from the system plane, with a human-readable reason and optional expiry, through a staged safety flow that previews scope impact before confirmation. +- **FR-003**: System MUST enforce the effective control state server-side before any in-scope findings lifecycle backfill start at `FindingsLifecycleBackfillRunbookService::start()`, any affected maintenance action, or any provider-backed restore execution begins. +- **FR-004**: System MUST show explicit paused-state feedback to entitled users on affected surfaces and MUST keep that feedback distinct from authorization failure. +- **FR-005**: System MUST preserve existing 404 vs 403 semantics for non-membership and missing capability checks even when a control is active, and capability-denied members MUST follow the existing surface-specific denial UX rather than operational-control helper text. +- **FR-006**: System MUST create no new `OperationRun`, no queued execution `RestoreRun`, no queued job, and no outbound provider execution when an in-scope action is blocked by an active control, and MUST NOT retroactively mutate already accepted or historical runs when a control is activated later. +- **FR-007**: System MUST audit every control activation, update, removal, and blocked execution decision with stable action IDs, actor, scope, reason, and timestamp; global control changes MUST be recorded as platform-plane audit events without assigning a false workspace or tenant owner, and blocked system-plane attempts without a concrete workspace or tenant MUST be recorded as platform-plane events with requested-scope metadata. +- **FR-008**: The findings-maintenance action currently gated by `config('tenantpilot.allow_admin_maintenance_actions')` MUST be migrated to the shared operational-control path and the local env gate retired for this in-scope behavior. +- **FR-009**: System MUST expose enough effective-state information on the controls page and affected execution surfaces to make the operator's next action clear without opening raw config or database detail. + +## UI Action Matrix *(mandatory when Filament is changed)* + +If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below. + +For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?), +RBAC gating (capability + enforcement helper), whether the mutation writes an audit log, and any exemption or exception used. + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| System ops controls surface | app/Filament/System/Pages/Ops/Controls.php | `Pause control`, `Resume control`, `Edit scope` (scope-impact preview + confirmation required for state changes) | Same-page control cards or modals; no row-click model | none beyond card actions | none | none in v1 | same-page actions only | `Review impact`, `Save changes`, `Cancel` in staged modal forms | yes | Management is platform-plane only; global changes audit as platform-plane events without workspace/tenant ownership; system users missing `platform.ops.controls.manage` receive 403 before page content renders | +| System runbooks launcher | app/Filament/System/Pages/Ops/Runbooks.php | `Preflight`, `Run...` | Same-page action modal and `View run` toast action | none | none | none | none | `Run`, `Cancel` in modal | yes | Existing start UX retained; blocked execution decisions are always audited | +| Findings list page | app/Filament/Resources/FindingResource/Pages/ListFindings.php | `Backfill findings lifecycle` (confirmation required) | Existing findings inspection model unchanged | unchanged | unchanged | unchanged | unchanged | N/A | yes | In-scope change replaces env gating with control evaluation and blocked execution audit | +| Restore run resource | app/Filament/Resources/RestoreRunResource.php | `New restore run` | Existing clickable-row/resource inspection model unchanged | existing row actions unchanged | existing grouped maintenance actions unchanged | existing empty-state CTA unchanged | existing view header unchanged | `Create restore run`, `Cancel` plus existing safety steps | yes | In-scope change gates only the `Execute restore` step inside the create flow; draft/setup labels and existing row/view actions remain unchanged | + +### Key Entities *(include if feature involves data)* + +- **Operational Control Definition**: The bounded catalog entry that identifies one controllable risky action, its canonical key, operator label, supported scopes, and default behavior. +- **Operational Control Activation**: The runtime safety record that pauses a control for either all workspaces or one specific workspace, including reason, optional expiry, and an owner display that resolves to the last mutating actor (`updated_by` when present, otherwise `created_by`). +- **Operational Control Decision**: The derived evaluation result returned to affected surfaces and service boundaries, including effective state, matched scope, reason, and whether execution may proceed. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In timed manual smoke, platform operators can pause or resume either first-slice control from the system plane in under 1 minute without editing environment variables, code, or database rows manually. +- **SC-002**: In blocked validation scenarios, 100% of attempted in-scope starts create no new execution run and no outbound provider-backed execution for the targeted scope. +- **SC-003**: In validation scenarios covering the affected surfaces, entitled users see explicit paused-state feedback on the first attempt in 100% of cases, while non-entitled users still receive 404 or 403 semantics as defined by RBAC rules. +- **SC-004**: Workspace-scoped activation affects only the targeted workspace in validation scenarios and leaves at least one non-targeted workspace unaffected for the same control. diff --git a/specs/242-operational-controls/tasks.md b/specs/242-operational-controls/tasks.md new file mode 100644 index 00000000..f50ba7a9 --- /dev/null +++ b/specs/242-operational-controls/tasks.md @@ -0,0 +1,187 @@ +--- + +description: "Task list for Operational Controls" + +--- + +# Tasks: Operational Controls + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/contracts/operational-controls.contract.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/quickstart.md` + +**Tests**: REQUIRED (Pest) for all runtime behavior changes in this slice. Keep proof in Unit + Feature lanes only. +**Operations**: Allowed starts must continue to reuse the shared OperationRun start UX. Blocked starts for `findings.lifecycle.backfill` and `restore.execute` must create no queued execution `OperationRun`, no queued execution `RestoreRun`, no queued job, and no provider-backed execution. Control activation governs new starts only and must not retroactively mutate already accepted runs. +**RBAC**: Management is platform-plane only under `/system`; affected execution surfaces stay on `/system` or `/admin/t/{tenant}`. Non-members remain `404`, members without the underlying capability remain `403`, and entitled users blocked only by an active operational control get explicit paused-state feedback. +**Organization**: Tasks are grouped by user story so each slice remains independently testable and bounded to the first-slice controls `findings.lifecycle.backfill` and `restore.execute`. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Prepare the local implementation lane and feature-local file layout without widening scope. + +- [x] T001 Start the local Sail environment with `cd apps/platform && ./vendor/bin/sail up -d` (script: `apps/platform/vendor/bin/sail`) +- [x] T002 Create the bounded feature-local directories under `apps/platform/app/Support/OperationalControls/`, `apps/platform/tests/Unit/Support/OperationalControls/`, `apps/platform/tests/Feature/System/OpsControls/`, `apps/platform/tests/Feature/System/OpsRunbooks/`, `apps/platform/tests/Feature/Findings/`, `apps/platform/tests/Feature/Restore/`, and `apps/platform/tests/Feature/OperationalControls/` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the single new persistence model, the bounded operational-controls support namespace, and the shared capability/audit plumbing that all stories depend on. + +**Checkpoint**: The repo has one `operational_control_activations` truth, one evaluator/catalog/decision support path, one platform capability, and shared audit IDs before any surface integration begins. + +- [x] T003 Create the activation migration in `apps/platform/database/migrations/*_create_operational_control_activations_table.php`, including partial unique indexes for active global and workspace-scoped rows +- [x] T004 Create the activation model in `apps/platform/app/Models/OperationalControlActivation.php` +- [x] T005 [P] Create the activation factory in `apps/platform/database/factories/OperationalControlActivationFactory.php` +- [x] T006 [P] Create the bounded catalog for `findings.lifecycle.backfill` and `restore.execute` in `apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php` +- [x] T007 [P] Create the derived decision object in `apps/platform/app/Support/OperationalControls/OperationalControlDecision.php` +- [x] T008 Create the shared evaluator for global and workspace-targeted activations, including foundational global-first precedence and expiry handling, in `apps/platform/app/Support/OperationalControls/OperationalControlEvaluator.php` +- [x] T009 [P] Add the platform management capability for the controls surface in `apps/platform/app/Support/Auth/PlatformCapabilities.php` and grant it in `apps/platform/database/seeders/PlatformUserSeeder.php` for the seeded local operator path +- [x] T010 [P] Add stable audit action IDs for pause, update, resume, and execution-blocked events in `apps/platform/app/Support/Audit/AuditActionId.php` +- [x] T011 Extend canonical audit plumbing for control scope, reason, expiry, requested-scope metadata, and blocked-execution evidence using `apps/platform/app/Services/Audit/AuditRecorder.php` for global control changes and blocked system all-tenant attempts and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` for workspace/tenant-scoped changes and blocked execution +- [x] T012 [P] Add catalog and evaluator unit coverage in `apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php` and `apps/platform/tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php` +- [x] T013 [P] Add global-first precedence and expiry coverage in `apps/platform/tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php` +- [x] T014 Run the foundational unit suite with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php` (tests: `apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php`) + +--- + +## Phase 3: User Story 1 — Pause A Risky Action Centrally (Priority: P1) 🎯 MVP + +**Goal**: Give platform operators one system-plane control center that can pause `findings.lifecycle.backfill` and make the runbook and findings surfaces show one explicit blocked-state path instead of env-driven disappearance. + +**Independent Test**: Pause `findings.lifecycle.backfill` globally from `/system/ops/controls`, then verify `/system/ops/runbooks` and `/admin/t/{tenant}/findings` both show the action truthfully for entitled users, block execution server-side, and show the same paused-state reason. + +### Tests for User Story 1 + +- [x] T015 [P] [US1] Add system-plane management coverage for staged scope-impact preview, controls-page 403 access denial, pause, update, resume, confirmation, global-audit ownership, on-demand audit history links, and audit logging in `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php` +- [x] T016 [P] [US1] Add findings lifecycle gate coverage for blocked `findings.lifecycle.backfill` starts at the shared `FindingsLifecycleBackfillRunbookService::start()` seam, including system-runbook callers, mandatory blocked-execution audit evidence, and truthful platform-plane ownership for blocked all-tenant attempts in `apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php` +- [x] T017 [P] [US1] Add findings header-action coverage for explicit blocked-state feedback, mandatory blocked-execution audit evidence, and no-start behavior in `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php` + +### Implementation for User Story 1 + +- [x] T018 [US1] Create the system ops controls page with native Filament sections, staged scope-impact preview plus confirmation-protected pause/resume actions, effective-state summaries, owner display, on-demand audit history links, and the mutation-path cleanup that deletes expired conflicting activations before writing a new pause in `apps/platform/app/Filament/System/Pages/Ops/Controls.php` +- [x] T019 [US1] Integrate the evaluator into `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php::start()` so the system runbooks page, CLI command, and deploy-hook command all honor the same blocked-start contract +- [x] T020 [US1] Replace the env-gated findings maintenance path by routing the tenant findings action through the shared findings lifecycle service/evaluator path, blocked-execution audit recording, and shared paused-state feedback in `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php` +- [x] T021 [US1] Retire the in-scope findings env gate from `apps/platform/config/tenantpilot.php` +- [x] T022 [US1] Run the US1 suite with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsControls/OperationalControlManagementTest.php tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php` (tests: `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php`) + +--- + +## Phase 4: User Story 2 — Block Execution Server-Side Without Masquerading As Auth (Priority: P1) + +**Goal**: Stop `restore.execute` before any queue, `OperationRun`, `RestoreRun`, or provider-backed execution starts while preserving normal `404` and `403` semantics. + +**Independent Test**: Activate `restore.execute`, attempt the restore execution flow as an entitled user, a non-member, and a member missing capability, and verify the outcomes are respectively paused-with-reason, `404`, and `403`, with no execution side effects on blocked paths. + +### Tests for User Story 2 + +- [x] T023 [P] [US2] Add restore execution gate coverage for blocked execution starts, mandatory blocked-execution audit evidence, no queued-execution `RestoreRun`/`OperationRun` side effects, provider-start suppression, and proof that a later pause does not retroactively mutate already accepted restore runs in `apps/platform/tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php` +- [x] T024 [P] [US2] Add explicit authorization-semantics and break-glass non-bypass coverage for non-member `404`, missing-capability `403` with the existing capability-denied UX, and paused-state feedback only for entitled users blocked by an operational control in `apps/platform/tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php` + +### Implementation for User Story 2 + +- [x] T025 [US2] Integrate the evaluator into the restore execution start seam so blocked `restore.execute` decisions stop before any queued execution `RestoreRun`, queued execution `OperationRun`, queue dispatch, or provider call in `apps/platform/app/Filament/Resources/RestoreRunResource.php` +- [x] T026 [US2] Reuse the shared provider-start gate for operational-control blocked outcomes instead of introducing restore-local runtime flags in `apps/platform/app/Services/Providers/ProviderOperationStartGate.php` +- [x] T027 [US2] Run the US2 suite with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php` (tests: `apps/platform/tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php`) + +--- + +## Phase 5: User Story 3 — Scope A Pause To One Workspace (Priority: P2) + +**Goal**: Allow platform operators to target one workspace without affecting unrelated workspaces, while keeping evaluator precedence and expiry behavior explicit and stable. + +**Independent Test**: Pause `restore.execute` or `findings.lifecycle.backfill` for Workspace A only, then verify the targeted workspace is blocked with the correct reason and a second workspace remains unaffected until the activation is removed or expires. + +### Tests for User Story 3 + +- [x] T028 [P] [US3] Extend controls-page feature coverage for workspace-targeted pause, update, expiry, and resume flows in `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php` +- [x] T029 [P] [US3] Add workspace-isolation coverage for targeted findings and restore blocking in `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php` and `apps/platform/tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php` + +### Implementation for User Story 3 + +- [x] T030 [US3] Add workspace-targeted scope selection, validation, and effective-state presentation to the controls page in `apps/platform/app/Filament/System/Pages/Ops/Controls.php` +- [x] T031 [US3] Extend `OperationalControlDecision` with workspace-targeted presentation details for matched scope, reason, expiry, and labels without redefining the foundational shared decision shape in `apps/platform/app/Support/OperationalControls/OperationalControlDecision.php` +- [x] T032 [US3] Ensure runbooks, findings, and restore all pass workspace context consistently into control evaluation in `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php`, `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`, and `apps/platform/app/Filament/Resources/RestoreRunResource.php` +- [x] T033 [US3] Run the US3 suite with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsControls/OperationalControlManagementTest.php tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php` (tests: `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php`) + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Lock down the shared-path contract, update feature artifacts if implementation details move, and run the narrow validation suite. + +- [x] T034 [P] Add a CI guard against ad-hoc `config(...)` or page-local runtime-control bypasses for the in-scope controls in `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` +- [x] T035 Update feature artifact close-out notes and final validation commands in `specs/242-operational-controls/plan.md` and `specs/242-operational-controls/quickstart.md` +- [x] T036 Run formatting on touched platform files with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` (target: `apps/platform/`) +- [x] T037 Run the full narrow validation suite from `specs/242-operational-controls/quickstart.md`, including the timed manual smoke for SC-001, across `apps/platform/tests/Unit/Support/OperationalControls/`, `apps/platform/tests/Feature/System/OpsControls/`, `apps/platform/tests/Feature/System/OpsRunbooks/`, `apps/platform/tests/Feature/Findings/`, `apps/platform/tests/Feature/Restore/`, and `apps/platform/tests/Feature/OperationalControls/` + +--- + +## Dependencies & Execution Order + +### User Story Dependency Graph + +```text +Phase 1 (Setup) + ↓ +Phase 2 (Foundation: activation persistence + evaluator + capability/audit plumbing) + ↓ +US1 (system controls page + findings lifecycle gating) ─┐ + ├─→ US3 (workspace-targeted scope + precedence/expiry) +US2 (restore execution gate + auth semantics) ──────────┘ +``` + +### Parallel Opportunities + +- Foundational tasks marked `[P]` can run in parallel once the migration/model direction is agreed. +- US1 tests for controls, runbooks, and findings can be authored in parallel because they target separate files. +- US2 restore and authorization-semantics tests can run in parallel while the restore seam work is isolated to `RestoreRunResource.php` and `ProviderOperationStartGate.php`. +- US3 extends existing US1/US2 tests and can parallelize the findings and restore workspace-isolation assertions while one person updates the controls page scope UI. + +--- + +## Parallel Example: User Story 1 + +```bash +Task: "Add system-plane management coverage in apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php" +Task: "Add runbook gating coverage in apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php" +Task: "Add findings header-action coverage in apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php" +Task: "Create the controls page in apps/platform/app/Filament/System/Pages/Ops/Controls.php" +``` + +--- + +## Parallel Example: User Story 2 + +```bash +Task: "Add restore execution gate coverage in apps/platform/tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php" +Task: "Add authorization-semantics coverage in apps/platform/tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php" +Task: "Integrate the evaluator into apps/platform/app/Filament/Resources/RestoreRunResource.php" +``` + +--- + +## Parallel Example: User Story 3 + +```bash +Task: "Extend controls-page workspace scope coverage in apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php" +Task: "Add workspace-isolation assertions in apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php" +Task: "Add workspace-isolation assertions in apps/platform/tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php" +Task: "Add workspace-targeted scope selection in apps/platform/app/Filament/System/Pages/Ops/Controls.php" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1) + +1. Complete Phase 1 and Phase 2. +2. Deliver the controls page plus `findings.lifecycle.backfill` integrations in US1. +3. Validate with the US1 feature suite before extending the second control. + +### Incremental Delivery + +1. US1 delivers the new system-plane management surface and removes the ad-hoc findings env gate. +2. US2 wires the same evaluator into `restore.execute` and proves blocked execution is not treated as authorization failure. +3. US3 adds workspace-targeted scope, precedence, and expiry without widening the catalog or support namespace. +4. Phase 6 adds the bypass guard, feature-artifact close-out, formatting, and the narrow validation pass. \ No newline at end of file -- 2.45.2 From 6053d87b992b22030617af9445ac4d7b22ae953e Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 26 Apr 2026 20:52:38 +0000 Subject: [PATCH 17/36] feat: implement product usage adoption telemetry (#281) ## Summary - implement spec 243 product usage adoption telemetry end-to-end - add bounded product usage event capture, aggregation, retention pruning, and system dashboard KPIs - add unit and feature coverage for telemetry capture, authorization, retention, privacy, and dashboard window behavior ## Validation - ran focused Pest test suites for telemetry and system dashboard behavior - ran Laravel Pint formatting - verified the system dashboard telemetry widget in the integrated browser ## Notes - branch: `243-product-usage-adoption-telemetry` - target: `dev` Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/281 --- .github/skills/spec-kit-end-to-end/SKILL.md | 939 ------------------ .../spec-kit-implementation-loop/SKILL.md | 447 +++++++++ .../skills/spec-kit-next-best-prep/SKILL.md | 562 +++++++++++ .../PruneProductUsageEventsCommand.php | 42 + .../TenantlessOperationRunViewer.php | 15 + .../app/Filament/Pages/TenantDashboard.php | 14 + .../app/Filament/System/Pages/Dashboard.php | 16 +- .../System/Widgets/ControlTowerKpis.php | 4 +- .../Widgets/ControlTowerRecentFailures.php | 4 +- .../Widgets/ControlTowerTopOffenders.php | 4 +- .../System/Widgets/ProductTelemetryKpis.php | 47 + .../platform/app/Models/ProductUsageEvent.php | 55 + .../EntraAdminRolesReportService.php | 25 + .../OnboardingDraftMutationService.php | 2 + .../Onboarding/OnboardingLifecycleService.php | 44 + .../app/Services/OperationRunService.php | 45 +- .../PermissionPostureFindingGenerator.php | 24 + .../app/Services/ReviewPackService.php | 31 + .../ProductTelemetryRecorder.php | 128 +++ .../ProductTelemetrySummaryQuery.php | 65 ++ .../ProductUsageEventCatalog.php | 102 ++ apps/platform/config/tenantpilot.php | 2 + .../factories/ProductUsageEventFactory.php | 72 ++ ...4038_create_product_usage_events_table.php | 34 + apps/platform/routes/console.php | 5 + .../EntraAdminRolesReportServiceTest.php | 1 + .../ScanEntraAdminRolesJobTest.php | 1 + .../ProductTelemetryOnboardingCaptureTest.php | 99 ++ ...ductTelemetryOperationStartCaptureTest.php | 73 ++ .../ProductTelemetryReportCaptureTest.php | 200 ++++ ...TelemetrySupportDiagnosticsCaptureTest.php | 98 ++ .../NoAdHocTelemetryBypassTest.php | 104 ++ .../ProductTelemetryAuthorizationTest.php | 45 + .../ProductTelemetryDashboardWidgetTest.php | 230 +++++ .../ProductTelemetryRetentionTest.php | 118 +++ .../Spec114/ControlTowerDashboardTest.php | 36 + .../ProductTelemetryRecorderTest.php | 69 ++ .../ProductTelemetrySafeMetadataTest.php | 83 ++ .../ProductTelemetrySummaryQueryTest.php | 100 ++ .../ProductUsageEventCatalogTest.php | 31 + .../checklists/requirements.md | 42 + .../plan.md | 202 ++++ .../spec.md | 256 +++++ .../tasks.md | 186 ++++ 44 files changed, 3754 insertions(+), 948 deletions(-) delete mode 100644 .github/skills/spec-kit-end-to-end/SKILL.md create mode 100644 .github/skills/spec-kit-implementation-loop/SKILL.md create mode 100644 .github/skills/spec-kit-next-best-prep/SKILL.md create mode 100644 apps/platform/app/Console/Commands/PruneProductUsageEventsCommand.php create mode 100644 apps/platform/app/Filament/System/Widgets/ProductTelemetryKpis.php create mode 100644 apps/platform/app/Models/ProductUsageEvent.php create mode 100644 apps/platform/app/Support/ProductTelemetry/ProductTelemetryRecorder.php create mode 100644 apps/platform/app/Support/ProductTelemetry/ProductTelemetrySummaryQuery.php create mode 100644 apps/platform/app/Support/ProductTelemetry/ProductUsageEventCatalog.php create mode 100644 apps/platform/database/factories/ProductUsageEventFactory.php create mode 100644 apps/platform/database/migrations/2026_04_26_194038_create_product_usage_events_table.php create mode 100644 apps/platform/tests/Feature/Onboarding/ProductTelemetryOnboardingCaptureTest.php create mode 100644 apps/platform/tests/Feature/Operations/ProductTelemetryOperationStartCaptureTest.php create mode 100644 apps/platform/tests/Feature/Reports/ProductTelemetryReportCaptureTest.php create mode 100644 apps/platform/tests/Feature/SupportDiagnostics/ProductTelemetrySupportDiagnosticsCaptureTest.php create mode 100644 apps/platform/tests/Feature/System/ProductTelemetry/NoAdHocTelemetryBypassTest.php create mode 100644 apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryAuthorizationTest.php create mode 100644 apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryDashboardWidgetTest.php create mode 100644 apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryRetentionTest.php create mode 100644 apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetryRecorderTest.php create mode 100644 apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetrySafeMetadataTest.php create mode 100644 apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetrySummaryQueryTest.php create mode 100644 apps/platform/tests/Unit/Support/ProductTelemetry/ProductUsageEventCatalogTest.php create mode 100644 specs/243-product-usage-adoption-telemetry/checklists/requirements.md create mode 100644 specs/243-product-usage-adoption-telemetry/plan.md create mode 100644 specs/243-product-usage-adoption-telemetry/spec.md create mode 100644 specs/243-product-usage-adoption-telemetry/tasks.md diff --git a/.github/skills/spec-kit-end-to-end/SKILL.md b/.github/skills/spec-kit-end-to-end/SKILL.md deleted file mode 100644 index 6a1c0ef9..00000000 --- a/.github/skills/spec-kit-end-to-end/SKILL.md +++ /dev/null @@ -1,939 +0,0 @@ ---- -name: spec-kit-end-to-end -description: End-to-end Spec Kit workflow for TenantPilot/TenantAtlas: select the next suitable spec candidate from roadmap/spec-candidates when needed, create or update spec.md/plan.md/tasks.md, optionally implement the active spec, run tests, browser smoke checks where applicable, post-implementation analysis, fix confirmed findings, and repeat until no in-scope findings remain or a stop condition is reached. ---- - -# Skill: Spec Kit End-to-End Workflow - -## Purpose - -Use this skill to run an end-to-end Spec Kit workflow for TenantPilot/TenantAtlas. - -This skill supports three modes: - -1. **Preparation only**: select or scope the next suitable feature from roadmap/spec-candidates and create or update `spec.md`, `plan.md`, and `tasks.md`. -2. **Implementation only**: implement an already prepared spec, run tests/checks, run strict post-implementation analysis, fix confirmed findings, and repeat until clean or a bounded stop condition is reached. -3. **End-to-end**: select or create a spec and then implement it in the same invocation, but only when the user explicitly requests end-to-end execution. - -The intended workflow is: - -```text -feature idea / roadmap item / spec candidate / active spec -→ determine requested mode -→ inspect repo truth, constitution, roadmap, spec candidates, existing specs, and relevant code -→ create or update spec.md + plan.md + tasks.md when preparation is needed -→ evaluate quality gates -→ implement only when the user explicitly asks for implementation or end-to-end execution -→ run relevant tests/checks -→ run browser smoke test when UI/user-facing flows are affected -→ run strict post-implementation analysis -→ fix confirmed in-scope findings -→ repeat test + analysis + fix loop until clean or bounded stop condition is reached -→ final report -``` - -## When to Use - -Use this skill when the user asks for any Spec Kit workflow around TenantPilot/TenantAtlas, including: - -- selecting the next best spec candidate from `docs/product/spec-candidates.md` and roadmap sources -- turning a feature idea, roadmap item, or candidate into `spec.md`, `plan.md`, and `tasks.md` -- preparing Spec Kit artifacts in one pass -- implementing an existing or newly prepared spec -- running implementation followed by strict analysis and fix iterations -- executing a full end-to-end flow from candidate selection to implementation verification - -Typical user prompts: - -```text -Nimm den nächsten sinnvollen Spec Candidate aus Roadmap/spec-candidates und mach spec, plan und tasks. -``` - -```text -Mach daraus spec, plan und tasks in einem Rutsch, aber noch nicht implementieren. -``` - -```text -Erstelle die Spec Kit Artefakte und implementiere sie danach mit Analyse/Fix-Loop. -``` - -```text -Implementiere die aktive Spec und analysiere danach, ob alles passt. -``` - -```text -Mach Spec Kit implement und danach analyse. Behebe alle Abweichungen und wiederhole bis sauber. -``` - -```text -Run end-to-end: choose next spec, create spec/plan/tasks, implement, analyze, fix until no in-scope findings remain. -``` - -## Hard Rules - -- Work strictly repo-based. -- Use the repository's actual Spec Kit workflow, scripts, templates, branch naming rules, and generated paths when available. -- Determine the requested mode before changing files: - - preparation only - - implementation only - - end-to-end preparation plus implementation -- Do not implement application code unless the user explicitly asks for implementation, `implement`, or end-to-end execution. -- When in preparation-only mode, create or update only Spec Kit preparation artifacts unless repository conventions require additional documentation artifacts. -- When in implementation mode, implement only the active or explicitly named Spec Kit feature. -- Do not manually invent spec numbers, branch names, or spec paths if Spec Kit provides a script or command for that. -- Do not bypass Spec Kit branch mechanics. -- Do not expand scope beyond the selected feature, `spec.md`, `plan.md`, and `tasks.md`. -- Do not silently add roadmap features, adjacent UX rewrites, speculative architecture, or unrelated refactors. -- Follow the repository constitution and existing Spec Kit conventions. -- Preserve TenantPilot/TenantAtlas terminology. -- Prefer small, reviewable, implementation-ready specs and patches over broad rewrites. -- Treat repository truth as authoritative over assumptions. -- If repository truth conflicts with the user-provided draft or spec, keep repository truth and document the deviation. -- If repository truth conflicts with implementation scope, stop and report the conflict unless there is an obvious minimal correction inside active spec scope. -- Fix only confirmed findings from tests, static checks, or post-implementation analysis. -- Fix all confirmed in-scope findings, regardless of severity, when they are safe and bounded. -- Do not leave Medium/Low findings open silently. If they are not fixed, document exactly why. -- Never hide failing tests, weaken assertions, delete meaningful coverage, or mark tasks complete without implementation evidence. -- Do not run destructive commands. -- Do not force checkout, reset, stash, rebase, merge, or delete branches. -- Do not perform database-destructive actions unless the repository test workflow explicitly requires isolated test database resets. -- Do not continue analysis/fix loops indefinitely. -- Do not move from preparation to implementation unless the Spec Readiness Gate passes or the user explicitly accepts the documented readiness risks. -- Do not move from implementation to final status unless the Test Gate, Browser Smoke Test Gate where applicable, and Post-Implementation Analysis Gate have been evaluated. -- Do not claim merge-readiness unless the Merge Readiness Gate passes. - -## Required Inputs - -The user should provide at least one of: - -- feature title and short goal -- full spec candidate -- roadmap item -- rough problem statement -- UX or architecture improvement idea -- explicit spec directory such as `specs/-/` -- instruction to use the current active Spec Kit feature -- instruction to choose the next best candidate from roadmap/spec-candidates - -If the input is incomplete, proceed with the smallest reasonable interpretation and document assumptions. - -If implementation is requested but the active spec cannot be determined safely, inspect the repository Spec Kit context first. If it is still ambiguous, stop and ask for the specific spec directory. - -## Required Repository Checks - -Always check the sources relevant to the requested mode. - -For preparation mode, always check: - -1. `.specify/memory/constitution.md` -2. `.specify/templates/` -3. `.specify/scripts/` -4. existing Spec Kit command usage or repository instructions, if present -5. current branch and git status -6. `specs/` -7. `docs/product/spec-candidates.md` -8. relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present -9. nearby existing specs with related terminology or scope -10. application code only as needed to avoid wrong naming, wrong architecture, duplicate concepts, impossible tasks, duplicated specs, or already-completed candidates - -For implementation mode, always check: - -1. active Spec Kit context / current branch -2. git status -3. `.specify/memory/constitution.md` -4. the active spec directory -5. `spec.md` -6. `plan.md` -7. `tasks.md` -8. relevant templates or conventions under `.specify/templates/` -9. nearby existing specs with related terminology or scope -10. application code surfaces referenced by the active spec -11. existing tests related to the changed behavior - -## Git and Branch Safety - -Before running any Spec Kit command or making implementation changes: - -1. Check the current branch. -2. Check whether the working tree is clean. -3. If there are unrelated uncommitted changes, stop and report them. Do not continue. -4. If the working tree only contains user-intended planning edits for this operation, continue cautiously. -5. Let Spec Kit create or switch to the correct feature branch when that is how the repository workflow works. -6. Do not force checkout, reset, stash, rebase, merge, or delete branches. -7. Do not overwrite existing specs. - -If the repo requires an explicit branch creation script for `specify`, use that script rather than manually creating the branch. - -## Mode Selection - -Select exactly one mode per invocation unless the user explicitly asks for end-to-end execution. - -### Preparation Only - -Use when the user asks to: - -- create spec/plan/tasks -- prepare a feature -- choose the next best spec candidate -- turn roadmap/spec-candidates into a spec -- run specify/plan/tasks/analyze without implementation -- avoid implementation - -Output is limited to Spec Kit preparation artifacts, preparation-artifact fixes, and final preparation summary. - -### Implementation Only - -Use when the user asks to: - -- implement an active spec -- run Spec Kit implement -- analyze after implementation -- fix implementation findings - -Requires an existing active or explicitly named spec. - -### End-to-End - -Use only when the user explicitly asks to: - -- choose/create the spec and then implement it -- run the full workflow -- go from candidate to implementation -- prepare and implement in one pass - -End-to-end mode must keep preparation and implementation phases clearly separated. - -End-to-end mode must pass the Candidate Selection Gate and Spec Readiness Gate before implementation begins. - -## Quality Gates - -Quality gates are mandatory checkpoints. They make the workflow safe for agentic execution without allowing uncontrolled scope expansion. - -### Gate 1: Candidate Selection Gate - -Required before creating a new spec from roadmap/spec-candidates. - -Pass criteria: - -- The selected candidate exists in roadmap/spec-candidate material or is directly provided by the user. -- The selected candidate is not already covered by an existing active or completed spec. -- The selected candidate aligns with current roadmap priorities or explicitly documented product direction. -- The candidate can be scoped as a small, reviewable, implementation-ready slice. -- Major adjacent concerns are listed as follow-up candidates instead of being hidden inside the primary scope. - -Fail behavior: - -- If no candidate satisfies the gate, stop and report the top candidates plus the reason none is ready. -- Do not invent a new roadmap direction to force progress. - -### Gate 2: Spec Readiness Gate - -Required before implementation starts, including end-to-end mode. - -Pass criteria: - -- `spec.md`, `plan.md`, and `tasks.md` exist. -- The spec has clear problem statement, user value, functional requirements, out-of-scope boundaries, acceptance criteria, assumptions, and risks. -- The plan identifies likely affected repo surfaces and does not contradict repository architecture. -- The tasks are small, ordered, verifiable, and include test/validation tasks. -- RBAC, workspace/tenant isolation, auditability, OperationRun semantics, evidence/result-truth, and UX requirements are addressed where relevant. -- No open question blocks safe implementation. -- The scope is small enough for a bounded implementation loop. - -Fail behavior: - -- In preparation-only mode, report the readiness gaps and provide the manual analysis prompt. -- In end-to-end mode, stop before implementation unless the user explicitly asked to proceed despite the documented readiness risks. -- Do not compensate for an unclear spec by inventing implementation scope. - -### Gate 3: Implementation Scope Gate - -Required before changing application code. - -Pass criteria: - -- The active spec directory is known. -- The implementation target is traceable to specific tasks in `tasks.md`. -- The affected files/surfaces are consistent with `plan.md` or clearly justified by repository truth. -- No required change would introduce unrelated product behavior. -- No required change conflicts with constitution, existing architecture, RBAC/isolation boundaries, or source-of-truth semantics. - -Fail behavior: - -- Stop before code changes and report the conflict or ambiguity. -- Suggest a minimal spec/plan/tasks correction if the issue is in the artifacts rather than the codebase. - -### Gate 4: Test Gate - -Required after implementation and after each fix iteration. - -Pass criteria: - -- Targeted tests for changed behavior pass. -- Relevant existing tests pass or failures are proven unrelated and documented. -- Static analysis, linting, formatting, or type checks used by the repository pass when applicable. -- Security/governance-relevant changes have backend, policy, or domain coverage; UI-only verification is not enough. -- Regression coverage exists for each fixed Blocker or High finding where practical. - -Fail behavior: - -- Fix in-scope failures before post-implementation analysis. -- If failures are unrelated or pre-existing, document evidence and continue only if they do not invalidate the active spec. -- Do not weaken tests to pass the gate. - -### Gate 5: Browser Smoke Test Gate - -Required before claiming implementation is ready for manual review/merge when the change affects Filament UI, Livewire interactions, navigation, forms, tables, actions, modals, dashboards, operation drilldowns, tenant/workspace context, or any user-facing flow. - -Not required for documentation-only, spec-only, backend-only, domain-only, enum-only, contract-only, or test-only changes unless those changes alter a user-facing flow. - -Pass criteria: - -- The relevant page or flow loads in a real browser or the repository's browser-testing harness. -- The primary action introduced or changed by the spec can be executed successfully. -- Expected UI states, labels, badges, actions, empty states, tables, forms, modals, and navigation are visible where relevant. -- Workspace/tenant context is preserved across the tested flow where relevant. -- RBAC/capability-dependent visibility behaves as expected where practical to verify. -- Livewire interactions complete without visible runtime errors. -- No relevant browser console errors occur. -- No failed network requests occur for the tested flow, except known unrelated development noise that is explicitly documented. -- OperationRun, audit, evidence, result, or support-diagnostic drilldowns work where relevant. -- The smoke-tested path is documented in the final response. - -Fail behavior: - -- Fix in-scope browser, UX, Livewire, navigation, or runtime failures before claiming merge-readiness. -- If a browser issue is unrelated existing debt, document evidence and residual risk. -- Do not treat a passing browser smoke test as a substitute for backend, policy, domain, security, feature, or integration tests. -- Do not expand the smoke test into a full E2E suite unless the user explicitly asks for that. - -### Gate 6: Post-Implementation Analysis Gate - -Required after implementation and after each fix iteration. - -Pass criteria: - -- The implementation has been checked against `spec.md`, `plan.md`, `tasks.md`, and constitution. -- All completed tasks have implementation evidence. -- No confirmed in-scope findings remain. -- Medium/Low findings are fixed when they are inside active spec scope, clearly bounded, and safe. -- Medium/Low findings that remain open are explicitly documented with one of these reasons: - - out of scope - - requires separate spec - - risky refactor - - existing unrelated debt - - not reproducible - - blocked by unclear product/architecture decision -- No scope expansion was introduced during fixes. - -Fail behavior: - -- Fix confirmed in-scope findings, regardless of severity, when the fix is safe and bounded. -- Stop instead of fixing when remediation would expand scope, contradict repo architecture, introduce risky refactors, or repeat the same failed fix twice. - -### Gate 7: Merge Readiness Gate - -Required before claiming the implementation is ready for manual review/merge. - -Pass criteria: - -- Spec Readiness Gate passed. -- Implementation Scope Gate passed. -- Test Gate passed. -- Browser Smoke Test Gate passed when applicable, or was explicitly marked not applicable with a reason. -- Post-Implementation Analysis Gate passed. -- `tasks.md` reflects actual completion status. -- No confirmed in-scope findings remain. -- All remaining findings are documented as out-of-scope, follow-up candidates, unrelated existing debt, or explicit residual risks. -- Final response includes changed files, tests/checks run, iterations performed, residual risks, and follow-up candidates. - -Fail behavior: - -- Do not claim merge-readiness. -- Report the failed gate, remaining risks, and the smallest recommended next action. - -## Candidate Selection Rules - -When the user asks for the next best spec from roadmap/spec-candidates: - -- Read `docs/product/spec-candidates.md`. -- Read relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present. -- Check existing specs to avoid duplicates. -- Prefer candidates that align with current roadmap priorities, platform foundations, enterprise UX, RBAC/isolation, auditability, observability, and governance workflow maturity. -- Prefer candidates that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers. -- Prefer small, implementation-ready slices over broad platform rewrites. -- If multiple candidates are plausible, choose one primary candidate and document why it was selected. -- Add non-selected relevant candidates as follow-up spec candidates, not hidden scope. -- Do not invent a candidate if existing roadmap/spec-candidate material provides a suitable one. -- Do not pick a spec only because it is listed first. -- Evaluate the Candidate Selection Gate before creating the spec directory. - -Evaluate candidates using these criteria: - -1. **Roadmap Fit**: Does it support the current roadmap sequence or unlock the next roadmap layer? -2. **Foundation Value**: Does it strengthen reusable platform foundations such as RBAC, isolation, auditability, evidence, OperationRun observability, provider boundaries, vocabulary, baseline/control/finding semantics, or enterprise UX patterns? -3. **Dependency Unblocking**: Does it make future specs smaller, safer, or more consistent? -4. **Scope Size**: Can it be implemented as a narrow, testable slice? -5. **Repo Readiness**: Does the repo already have enough structure to implement the next slice safely? -6. **Risk Reduction**: Does it reduce current architectural or product risk? -7. **User/Product Value**: Does it produce visible operator value or make the platform more sellable without heavy scope? - -## Required Selection Output Before Spec Kit Execution - -Before running the Spec Kit flow, identify: - -- selected candidate title -- source location in roadmap/spec-candidates -- why it was selected -- why close alternatives were deferred -- roadmap relationship -- smallest viable implementation slice -- proposed concise feature description to feed into `specify` - -The feature description must be product- and behavior-oriented. It should not be a low-level implementation plan. - -## Spec Kit Preparation Flow - -Use this section when the selected mode is preparation-only or end-to-end. - -### Step 1: Determine the repository's Spec Kit command pattern - -Inspect repository instructions and scripts to identify how this repo expects Spec Kit to be run. - -Common locations to inspect: - -```text -.specify/scripts/ -.specify/templates/ -.specify/memory/constitution.md -.github/prompts/ -.github/skills/ -README.md -specs/ -``` - -Use the repo-specific mechanism if present. - -### Step 2: Run `specify` - -Run the repository's `specify` flow using the selected candidate and the smallest viable slice. - -The `specify` input should include: - -- selected candidate title -- problem statement -- operator/user value -- roadmap relationship -- out-of-scope boundaries -- key acceptance criteria -- important enterprise constraints - -Let Spec Kit create the correct branch and spec location if that is the repo's configured behavior. - -### Step 3: Run `plan` - -Run the repository's `plan` flow for the generated spec. - -The `plan` input should keep the scope tight and should require repo-based alignment with: - -- constitution -- existing architecture -- workspace/tenant isolation -- RBAC -- OperationRun/observability where relevant -- evidence/snapshot/truth semantics where relevant -- Filament/Livewire conventions where relevant -- test strategy - -### Step 4: Run `tasks` - -Run the repository's `tasks` flow for the generated plan. - -The generated tasks must be: - -- ordered -- small -- testable -- grouped by phase -- limited to the selected scope -- suitable for later implementation or manual analysis before implementation - -### Step 5: Run preparation `analyze` - -Run the repository's `analyze` flow against the generated Spec Kit artifacts when the repository supports it. - -Analyze must check: - -- consistency between `spec.md`, `plan.md`, and `tasks.md` -- constitution alignment -- roadmap alignment -- whether the selected candidate was narrowed safely -- whether tasks are complete enough for implementation -- whether tasks accidentally require scope not described in the spec -- whether plan details conflict with repository architecture or terminology -- whether implementation risks are documented instead of silently ignored - -In preparation-only mode, do not use analyze as a trigger to implement application code. - -### Step 6: Fix preparation-artifact issues only - -If preparation analyze finds issues, fix only Spec Kit preparation artifacts such as: - -- `spec.md` -- `plan.md` -- `tasks.md` -- generated Spec Kit metadata files, if the repository uses them - -Allowed fixes include: - -- clarify requirements -- tighten scope -- move out-of-scope work into follow-up candidates -- correct terminology -- add missing tasks -- remove tasks not backed by the spec -- align plan language with repository architecture -- add missing acceptance criteria or validation tasks - -Forbidden fixes in preparation-only mode include: - -- modifying application code -- creating migrations -- editing models, services, jobs, policies, Filament resources, Livewire components, tests, or commands -- running implementation or test-fix loops -- changing runtime behavior - -### Step 7: Evaluate the Spec Readiness Gate - -After preparation analyze has passed or preparation-artifact issues have been fixed, evaluate the Spec Readiness Gate. - -In preparation-only mode, stop after this gate and do not implement. - -## Spec Directory Rules - -When creating a new spec directory, use the repository's Spec Kit-generated directory or path. - -If the repository does not provide a command for spec setup, use the next valid spec number and a kebab-case slug: - -```text -specs/-/ -``` - -The exact number must be derived from the current repository state and existing numbering conventions. - -Create or update preparation artifacts inside the selected spec directory: - -```text -specs/-/spec.md -specs/-/plan.md -specs/-/tasks.md -``` - -If the repository templates require additional preparation files, create them only when this is consistent with existing Spec Kit conventions. - -## `spec.md` Requirements - -The spec must be product- and behavior-oriented. It should avoid premature implementation detail unless needed for correctness. - -Include: - -- Feature title -- Problem statement -- Business/product value -- Primary users/operators -- User stories -- Functional requirements -- Non-functional requirements -- UX requirements -- RBAC/security requirements -- Auditability/observability requirements -- Data/truth-source requirements where relevant -- Out of scope -- Acceptance criteria -- Success criteria -- Risks -- Assumptions -- Open questions - -TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles: - -- workspace/tenant isolation -- capability-first RBAC -- auditability -- operation/result truth separation -- source-of-truth clarity -- calm enterprise operator UX -- progressive disclosure where useful -- no false positive calmness - -## `plan.md` Requirements - -The plan must be repo-aware and implementation-oriented, but it must not make code changes by itself. - -Include: - -- Technical approach -- Existing repository surfaces likely affected -- Domain/model implications -- UI/Filament implications -- Livewire implications where relevant -- OperationRun/monitoring implications where relevant -- RBAC/policy implications -- Audit/logging/evidence implications where relevant -- Data/migration implications where relevant -- Test strategy -- Rollout considerations -- Risk controls -- Implementation phases - -The plan should clearly distinguish where relevant: - -- execution truth -- artifact truth -- backup/snapshot truth -- recovery/evidence truth -- operator next action - -## `tasks.md` Requirements - -Tasks must be ordered, small, and verifiable. - -Include: - -- checkbox tasks -- phase grouping -- tests before or alongside implementation tasks where practical -- final validation tasks -- documentation/update tasks if needed -- explicit non-goals where useful - -Avoid vague tasks such as: - -```text -Clean up code -Refactor UI -Improve performance -Make it enterprise-ready -``` - -Prefer concrete tasks such as: - -```text -- [ ] Add a feature test covering workspace isolation for . -- [ ] Update to display . -- [ ] Add policy coverage for . -``` - -If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths. - -## Preparation Scope Control - -If the requested feature implies multiple independent concerns, create one primary spec for the smallest valuable slice and add a `Follow-up spec candidates` section. - -Examples of follow-up candidates: - -- assigned findings -- pending approvals -- personal work queue -- notification delivery settings -- evidence pack export hardening -- operation monitoring refinements -- autonomous governance decision surfaces - -Do not force all follow-up candidates into the primary spec. - -## Implementation Loop - -Only execute this section when the selected mode is implementation-only or end-to-end. - -Execute the loop in bounded phases: - -1. Evaluate the Spec Readiness Gate. -2. Evaluate the Implementation Scope Gate before changing application code. -3. Implement the active Spec Kit feature scope. -4. Run targeted tests and relevant static/dynamic checks. -5. Evaluate the Test Gate. -6. Run a Browser Smoke Test when the change affects UI/user-facing flows. -7. Evaluate the Browser Smoke Test Gate as passed, failed, or not applicable with a reason. -8. Run strict post-implementation analysis against spec, plan, tasks, constitution, changed code, changed tests, browser smoke results where applicable, and relevant existing patterns. -9. Evaluate the Post-Implementation Analysis Gate. -10. Identify confirmed findings by severity: Blocker, High, Medium, Low. -11. Fix all confirmed in-scope findings regardless of severity when safe and bounded. -12. Do not fix findings that require scope expansion, risky unrelated refactors, or architectural/product decisions outside the active spec; document them as follow-up/residual risks with reasons. -13. Re-run relevant tests and browser smoke checks where applicable after fixes. -14. Repeat test + browser smoke + analysis + fix loop until no confirmed in-scope findings remain or a stop condition is reached. -15. Evaluate the Merge Readiness Gate. -16. Report final implementation status, changed files, tests, browser smoke result, residual risks, failed/passed gates, and manual review prompt. - -## Stop Conditions - -Stop the implementation loop when any of the following is true: - -- No confirmed in-scope findings remain. -- The same finding appears twice after attempted fixes. -- A required fix conflicts with the spec, plan, constitution, or repository architecture. -- A required fix would expand scope beyond the active spec. -- A required fix would require a risky unrelated refactor. -- A required fix depends on an unresolved product or architecture decision. -- Tests reveal an unrelated pre-existing failure that cannot be safely fixed inside the active spec. -- Browser smoke testing reveals an unrelated pre-existing UI/runtime failure that cannot be safely fixed inside the active spec. -- Three analysis/fix iterations have already been completed. -- The repository state is ambiguous enough that continuing would risk damaging architecture or data semantics. - -When stopping before full cleanliness, report exactly why the loop stopped and what remains. - -## Post-Implementation Analysis Prompt - -Use this prompt internally after implementation and after each fix iteration: - -```markdown -Du bist ein Senior Staff Software Engineer, Software Architect und Enterprise SaaS Reviewer. - -Analysiere die Implementierung der aktiven Spec streng repo-basiert. - -Ziel: -Prüfe, ob die Umsetzung vollständig, konsistent, getestet und constitution-konform ist. - -Prüfe gegen: -- spec.md -- plan.md -- tasks.md -- .specify/memory/constitution.md -- geänderte Anwendungscodes -- geänderte Tests -- Browser-Smoke-Test-Ergebnis, falls UI/user-facing Flows betroffen sind -- bestehende Repository-Patterns - -Wichtig: -- Keine Spekulation ohne Repo-Beleg. -- Keine Scope-Erweiterung. -- Keine neuen Produktideen als Pflicht-Fixes. -- Findings nach Blocker, High, Medium, Low gruppieren. -- Für jedes Finding konkrete Datei-/Code-Belege nennen. -- Für jedes Finding eine minimale Remediation nennen. -- Separat ausweisen, welche Findings innerhalb der aktiven Spec behoben werden müssen. -- Medium/Low Findings innerhalb der aktiven Spec ebenfalls zur Behebung markieren, wenn sie sicher und bounded sind. -- Bei UI-/Filament-/Livewire-Änderungen prüfen, ob ein Browser Smoke Test durchgeführt wurde und ob der getestete Operator-Flow wirklich funktioniert. -- Findings, die nicht behoben werden sollen, nur als Follow-up/Residual Risk ausweisen, wenn sie out of scope, risky refactor, unrelated existing debt, not reproducible oder durch eine offene Produkt-/Architekturentscheidung blockiert sind. -- Wenn keine bestätigten In-Scope Findings verbleiben, klare Implementierungsfreigabe geben. -``` - -## Task Completion Rules - -- Keep `tasks.md` aligned with actual implementation status. -- Check off tasks only after the implementation and test evidence exists. -- If a task is obsolete because repository truth proves a different path, update the task note with the reason instead of silently deleting it. -- If a task cannot be completed inside scope, leave it unchecked and report why. - -## Testing Rules - -- Add or update tests for all changed business behavior. -- Include RBAC and workspace/tenant isolation tests where relevant. -- Include OperationRun, audit, evidence, or result-truth tests where relevant. -- Prefer regression tests for every fixed Blocker or High finding. -- Add regression tests for Medium/Low findings when the behavior is important and testable without excessive churn. -- Do not weaken tests to pass the suite. -- Do not treat a green UI path as sufficient without backend or policy coverage when the behavior is security- or governance-relevant. - -## Browser Smoke Test Rules - -Apply these rules when the active spec changes Filament UI, Livewire interactions, navigation, forms, tables, actions, modals, dashboards, operation drilldowns, tenant/workspace context, or any user-facing flow. - -The browser smoke test should be narrow and focused. It is not a full E2E suite unless explicitly requested. - -Minimum smoke path: - -1. Open the relevant page or entry point. -2. Confirm the expected workspace/tenant context where relevant. -3. Confirm the changed or newly introduced UI element is visible. -4. Execute the primary action or interaction changed by the spec. -5. Confirm the expected result state, notification, redirect, table update, modal state, operation link, or drilldown. -6. Check for relevant console errors. -7. Check for failed network requests related to the tested flow. -8. Document the tested path in the final response. - -For TenantPilot/TenantAtlas, pay special attention to: - -- Filament actions and header actions -- Livewire polling, modals, validation, and actions -- workspace/tenant context preservation -- RBAC/capability-dependent action visibility -- OperationRun links and drilldown continuity -- audit/evidence/result/support-diagnostic drilldowns where relevant -- empty states, badges, labels, and decision guidance where relevant - -Browser smoke testing is required for UI/user-facing changes and optional for backend-only changes. - -Do not treat browser smoke success as proof that backend security, policies, domain logic, auditability, or workspace/tenant isolation are correct. Those still require automated tests or repo-based verification. - -## Failure Handling - -If a Spec Kit command, preparation analyze phase, implementation step, test phase, browser smoke phase, or post-implementation analysis fails: - -1. Stop at the relevant gate or stop condition. -2. Report the failing command or phase. -3. Summarize the error. -4. Do not attempt unrelated implementation as a workaround. -5. Suggest the smallest safe next action. - -If the branch or working tree state is unsafe: - -1. Stop before running Spec Kit commands or implementation changes. -2. Report the current branch and relevant uncommitted files. -3. Ask the user to commit, stash, or move to a clean worktree. - -## Final Response Requirements - -For preparation-only mode, respond with: - -1. Selected candidate and why it was chosen -2. Why close alternatives were deferred -3. Current branch after Spec Kit execution, if changed -4. Generated spec path -5. Files created or updated by Spec Kit -6. Preparation analyze result summary -7. Preparation-artifact fixes applied after analyze -8. Assumptions made -9. Open questions, if any -10. Quality gates evaluated and their result -11. Recommended next implementation prompt -12. Explicit statement that no application implementation was performed - -For implementation-only or end-to-end mode, respond with: - -1. Active spec directory -2. Summary of implemented changes -3. Tests/checks run and their results -4. Browser smoke test result, tested path, or not-applicable reason -5. Quality gates passed/failed and number of analysis/fix iterations performed -6. Remaining in-scope findings, if any -7. Residual risks and follow-up candidates, if relevant -8. Files changed -9. Explicit statement whether the Merge Readiness Gate passed and whether the implementation is ready for manual review/merge - -Keep the final response concise, but include enough detail for the user to continue immediately. - -## Manual Review Prompts - -For preparation-only mode, provide a ready-to-copy prompt like this, adapted to the generated spec branch/path: - -```markdown -Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer. - -Analysiere die neu erstellte Spec `` streng repo-basiert. - -Ziel: -Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar und constitution-konform sind. - -Wichtig: -- Keine Implementierung. -- Keine Codeänderungen. -- Keine Scope-Erweiterung. -- Prüfe nur gegen Repo-Wahrheit. -- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs. -- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor. -- Wenn alles passt, gib eine klare Implementierungsfreigabe. -``` - -For preparation-only mode, also provide a ready-to-copy implementation prompt after analyze has passed or preparation-artifact issues have been fixed: - -```markdown -Du bist ein Senior Staff Software Engineer für TenantPilot/TenantAtlas. - -Implementiere die vorbereitete Spec `` streng anhand von `tasks.md`. - -Wichtig: -- Arbeite task-sequenziell. -- Ändere nur Dateien, die für die jeweilige Task notwendig sind. -- Halte dich an `spec.md`, `plan.md`, `tasks.md` und die Constitution. -- Keine Scope-Erweiterung. -- Keine Opportunistic Refactors. -- Führe passende Tests nach sinnvollen Task-Gruppen aus. -- Wenn eine Task unklar oder falsch ist, stoppe und dokumentiere den Konflikt statt frei zu improvisieren. -- Am Ende liefere geänderte Dateien, Teststatus, offene Risiken und nicht erledigte Tasks. -``` - -For implementation-only or end-to-end mode, provide a ready-to-copy prompt like this, adapted to the active spec number and slug: - -```markdown -Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer. - -Führe eine finale manuelle Review der implementierten Spec `-` streng repo-basiert durch. - -Ziel: -Prüfe, ob die Implementierung nach dem Agenten-Loop wirklich merge-ready ist. - -Wichtig: -- Keine Implementierung. -- Keine Codeänderungen. -- Keine Scope-Erweiterung. -- Prüfe gegen spec.md, plan.md, tasks.md und constitution.md. -- Prüfe die geänderten Dateien, Tests, Browser-Smoke-Test-Ergebnis, RBAC, Workspace-/Tenant-Isolation, Auditability, UX und OperationRun-Semantik, soweit relevant. -- Benenne nur konkrete Findings mit Repo-Beleg. -- Gib am Ende eine klare Entscheidung: Merge-ready, merge-ready with notes, oder not merge-ready. -``` - -## Example Invocations - -User: - -```text -Nutze den Skill spec-kit-end-to-end. -Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec. -Führe danach GitHub Spec Kit specify, plan, tasks und analyze in einem Rutsch aus. -Behebe alle analyze-Issues in den Spec-Kit-Artefakten. -Keine Application-Implementierung. -``` - -Expected behavior: - -1. Inspect constitution, Spec Kit scripts/templates, specs, roadmap, and spec candidates. -2. Check branch and working tree safety. -3. Compare candidate suitability. -4. Select the next best candidate. -5. Evaluate the Candidate Selection Gate. -6. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup. -7. Run the repository's real Spec Kit `plan` flow. -8. Run the repository's real Spec Kit `tasks` flow. -9. Run the repository's real Spec Kit preparation `analyze` flow. -10. Fix analyze issues only in Spec Kit preparation artifacts. -11. Evaluate the Spec Readiness Gate. -12. Stop before application implementation. -13. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, gates, and next implementation prompt. - -User: - -```text -Implementiere die aktive Spec. Danach analyse gegen spec/plan/tasks/constitution ausführen, alle in-scope Findings beheben und wiederhole bis sauber. -``` - -Expected behavior: - -1. Inspect active Spec Kit context, constitution, spec, plan, tasks, relevant code, and relevant tests. -2. Evaluate the Spec Readiness Gate and Implementation Scope Gate. -3. Implement only the active spec scope. -4. Run targeted tests and relevant checks. -5. Evaluate the Test Gate. -6. Run and evaluate Browser Smoke Test when UI/user-facing flows are affected. -7. Run post-implementation analysis. -8. Fix all confirmed in-scope findings regardless of severity when safe and bounded. -9. Repeat test + browser smoke + analysis + fix loop up to the stop conditions. -10. Evaluate the Merge Readiness Gate. -11. Report final status, changed files, tests, browser smoke result, residual risks, gates, and manual review prompt. - -User: - -```text -Run end-to-end: wähle die nächste sinnvolle Spec aus spec-candidates/roadmap, erstelle spec/plan/tasks, implementiere sie danach und wiederhole analyse/fix bis sauber. -``` - -Expected behavior: - -1. Run preparation mode first. -2. Clearly report the selected candidate and created spec directory. -3. Continue into implementation mode only because the user explicitly requested end-to-end execution. -4. Implement only the newly created active spec scope. -5. Run tests/checks, browser smoke checks where applicable, post-implementation analysis, and bounded fix iterations. -6. Fix all confirmed in-scope findings regardless of severity when safe and bounded. -7. Report final implementation status, gates, browser smoke result, and residual risks. -``` \ No newline at end of file diff --git a/.github/skills/spec-kit-implementation-loop/SKILL.md b/.github/skills/spec-kit-implementation-loop/SKILL.md new file mode 100644 index 00000000..bcf2ca30 --- /dev/null +++ b/.github/skills/spec-kit-implementation-loop/SKILL.md @@ -0,0 +1,447 @@ +--- +name: spec-kit-implementation-loop +description: Implement an existing TenantPilot/TenantAtlas Spec Kit feature, run tests, browser smoke checks where applicable, post-implementation analysis, fix all confirmed in-scope findings when safe and bounded, and repeat until no in-scope findings remain or a stop condition is reached. +--- + +# Skill: Spec Kit Implementation Loop + +## Purpose + +Use this skill to implement an already prepared TenantPilot/TenantAtlas Spec Kit feature and verify it with a bounded implementation loop. + +This skill assumes `spec.md`, `plan.md`, and `tasks.md` already exist and have passed preparation readiness or have been explicitly accepted by the user. + +The intended workflow is: + +```text +active or explicitly named spec +→ inspect repo truth, constitution, spec, plan, tasks, and relevant code/tests +→ evaluate implementation gates +→ implement strictly task-by-task +→ run relevant tests/checks +→ run browser smoke test when UI/user-facing flows are affected +→ run strict post-implementation analysis +→ fix confirmed in-scope findings +→ repeat test + browser smoke + analysis + fix loop until clean or bounded stop condition is reached +→ final implementation report +``` + +## When to Use + +Use this skill when the user asks to: + +- implement an active or explicitly named Spec Kit feature +- run Spec Kit implement +- analyze after implementation +- fix implementation findings +- repeat implementation verification until no confirmed in-scope findings remain +- run tests and browser smoke checks after implementation + +Typical user prompts: + +```text +Implementiere die aktive Spec und analysiere danach, ob alles passt. +``` + +```text +Implementiere specs/243-product-usage-adoption-telemetry streng nach tasks.md. +``` + +```text +Mach Spec Kit implement und danach analyse. Behebe alle Abweichungen und wiederhole bis sauber. +``` + +```text +Implementiere die vorbereitete Spec. Danach Tests, Browser Smoke Test falls UI betroffen ist, Analyse und Fix-Loop bis keine In-Scope Findings mehr offen sind. +``` + +## Hard Rules + +- Work strictly repo-based. +- Implement only the active or explicitly named Spec Kit feature. +- Do not choose a new candidate. +- Do not create a new spec. +- Do not expand scope beyond `spec.md`, `plan.md`, and `tasks.md`. +- Do not silently add roadmap features, adjacent UX rewrites, speculative architecture, or unrelated refactors. +- Follow the repository constitution and existing Spec Kit conventions. +- Preserve TenantPilot/TenantAtlas terminology. +- Prefer small, reviewable patches over broad rewrites. +- Treat repository truth as authoritative over assumptions. +- If repository truth conflicts with implementation scope, stop and report the conflict unless there is an obvious minimal correction inside active spec scope. +- Fix only confirmed findings from tests, static checks, browser smoke checks, or post-implementation analysis. +- Fix all confirmed in-scope findings, regardless of severity, when they are safe and bounded. +- Do not leave Medium/Low findings open silently. If they are not fixed, document exactly why. +- Never hide failing tests, weaken assertions, delete meaningful coverage, or mark tasks complete without implementation evidence. +- Do not run destructive commands. +- Do not force checkout, reset, stash, rebase, merge, or delete branches. +- Do not perform database-destructive actions unless the repository test workflow explicitly requires isolated test database resets. +- Do not continue analysis/fix loops indefinitely. +- Do not move from implementation to final status unless the Test Gate, Browser Smoke Test Gate where applicable, and Post-Implementation Analysis Gate have been evaluated. +- Do not claim merge-readiness unless the Merge Readiness Gate passes. + +## Required Inputs + +The user should provide at least one of: + +- explicit spec directory such as `specs/-/` +- instruction to use the current active Spec Kit feature +- instruction to implement the prepared/current spec + +If the active spec cannot be determined safely, inspect the repository Spec Kit context first. If it is still ambiguous, stop and ask for the specific spec directory. + +## Required Repository Checks + +Always check: + +1. active Spec Kit context / current branch +2. git status +3. `.specify/memory/constitution.md` +4. the active spec directory +5. `spec.md` +6. `plan.md` +7. `tasks.md` +8. relevant templates or conventions under `.specify/templates/` +9. nearby existing specs with related terminology or scope +10. application code surfaces referenced by the active spec +11. existing tests related to the changed behavior + +## Git and Branch Safety + +Before making implementation changes: + +1. Check the current branch. +2. Check whether the working tree is clean. +3. If there are unrelated uncommitted changes, stop and report them. Do not continue. +4. If the working tree only contains user-intended changes for this operation, continue cautiously. +5. Do not force checkout, reset, stash, rebase, merge, or delete branches. +6. Do not overwrite unrelated work. + +## Quality Gates + +### Gate 1: Spec Readiness Gate + +Required before implementation starts. + +Pass criteria: + +- `spec.md`, `plan.md`, and `tasks.md` exist. +- The spec has clear problem statement, user value, functional requirements, out-of-scope boundaries, acceptance criteria, assumptions, and risks. +- The plan identifies likely affected repo surfaces and does not contradict repository architecture. +- The tasks are small, ordered, verifiable, and include test/validation tasks. +- RBAC, workspace/tenant isolation, auditability, OperationRun semantics, evidence/result-truth, and UX requirements are addressed where relevant. +- No open question blocks safe implementation. +- The scope is small enough for a bounded implementation loop. + +Fail behavior: + +- Stop before implementation. +- Report readiness gaps. +- Do not compensate for an unclear spec by inventing implementation scope. + +### Gate 2: Implementation Scope Gate + +Required before changing application code. + +Pass criteria: + +- The active spec directory is known. +- The implementation target is traceable to specific tasks in `tasks.md`. +- The affected files/surfaces are consistent with `plan.md` or clearly justified by repository truth. +- No required change would introduce unrelated product behavior. +- No required change conflicts with constitution, existing architecture, RBAC/isolation boundaries, or source-of-truth semantics. + +Fail behavior: + +- Stop before code changes and report the conflict or ambiguity. +- Suggest a minimal spec/plan/tasks correction if the issue is in the artifacts rather than the codebase. + +### Gate 3: Test Gate + +Required after implementation and after each fix iteration. + +Pass criteria: + +- Targeted tests for changed behavior pass. +- Relevant existing tests pass or failures are proven unrelated and documented. +- Static analysis, linting, formatting, or type checks used by the repository pass when applicable. +- Security/governance-relevant changes have backend, policy, or domain coverage; UI-only verification is not enough. +- Regression coverage exists for each fixed Blocker or High finding where practical. + +Fail behavior: + +- Fix in-scope failures before post-implementation analysis. +- If failures are unrelated or pre-existing, document evidence and continue only if they do not invalidate the active spec. +- Do not weaken tests to pass the gate. + +### Gate 4: Browser Smoke Test Gate + +Required before claiming implementation is ready for manual review/merge when the change affects Filament UI, Livewire interactions, navigation, forms, tables, actions, modals, dashboards, operation drilldowns, tenant/workspace context, or any user-facing flow. + +Not required for backend-only, domain-only, enum-only, contract-only, or test-only changes unless those changes alter a user-facing flow. + +Pass criteria: + +- The relevant page or flow loads in a real browser or the repository's browser-testing harness. +- The primary action introduced or changed by the spec can be executed successfully. +- Expected UI states, labels, badges, actions, empty states, tables, forms, modals, and navigation are visible where relevant. +- Workspace/tenant context is preserved across the tested flow where relevant. +- RBAC/capability-dependent visibility behaves as expected where practical to verify. +- Livewire interactions complete without visible runtime errors. +- No relevant browser console errors occur. +- No failed network requests occur for the tested flow, except known unrelated development noise that is explicitly documented. +- OperationRun, audit, evidence, result, or support-diagnostic drilldowns work where relevant. +- The smoke-tested path is documented in the final response. + +Fail behavior: + +- Fix in-scope browser, UX, Livewire, navigation, or runtime failures before claiming merge-readiness. +- If a browser issue is unrelated existing debt, document evidence and residual risk. +- Do not treat a passing browser smoke test as a substitute for backend, policy, domain, security, feature, or integration tests. +- Do not expand the smoke test into a full E2E suite unless the user explicitly asks for that. + +### Gate 5: Post-Implementation Analysis Gate + +Required after implementation and after each fix iteration. + +Pass criteria: + +- The implementation has been checked against `spec.md`, `plan.md`, `tasks.md`, and constitution. +- All completed tasks have implementation evidence. +- No confirmed in-scope findings remain. +- Medium/Low findings are fixed when they are inside active spec scope, clearly bounded, and safe. +- Medium/Low findings that remain open are explicitly documented with one of these reasons: + - out of scope + - requires separate spec + - risky refactor + - existing unrelated debt + - not reproducible + - blocked by unclear product/architecture decision +- No scope expansion was introduced during fixes. + +Fail behavior: + +- Fix confirmed in-scope findings, regardless of severity, when the fix is safe and bounded. +- Stop instead of fixing when remediation would expand scope, contradict repo architecture, introduce risky refactors, or repeat the same failed fix twice. + +### Gate 6: Merge Readiness Gate + +Required before claiming the implementation is ready for manual review/merge. + +Pass criteria: + +- Spec Readiness Gate passed. +- Implementation Scope Gate passed. +- Test Gate passed. +- Browser Smoke Test Gate passed when applicable, or was explicitly marked not applicable with a reason. +- Post-Implementation Analysis Gate passed. +- `tasks.md` reflects actual completion status. +- No confirmed in-scope findings remain. +- All remaining findings are documented as out-of-scope, follow-up candidates, unrelated existing debt, or explicit residual risks. +- Final response includes changed files, tests/checks run, browser smoke result, iterations performed, residual risks, and follow-up candidates. + +Fail behavior: + +- Do not claim merge-readiness. +- Report the failed gate, remaining risks, and the smallest recommended next action. + +## Implementation Loop + +Execute the loop in bounded phases: + +1. Evaluate the Spec Readiness Gate. +2. Evaluate the Implementation Scope Gate before changing application code. +3. Implement the active Spec Kit feature scope task-by-task. +4. Run targeted tests and relevant static/dynamic checks. +5. Evaluate the Test Gate. +6. Run a Browser Smoke Test when the change affects UI/user-facing flows. +7. Evaluate the Browser Smoke Test Gate as passed, failed, or not applicable with a reason. +8. Run strict post-implementation analysis against spec, plan, tasks, constitution, changed code, changed tests, browser smoke results where applicable, and relevant existing patterns. +9. Evaluate the Post-Implementation Analysis Gate. +10. Identify confirmed findings by severity: Blocker, High, Medium, Low. +11. Fix all confirmed in-scope findings regardless of severity when safe and bounded. +12. Do not fix findings that require scope expansion, risky unrelated refactors, or architectural/product decisions outside the active spec; document them as follow-up/residual risks with reasons. +13. Re-run relevant tests and browser smoke checks where applicable after fixes. +14. Repeat test + browser smoke + analysis + fix loop until no confirmed in-scope findings remain or a stop condition is reached. +15. Evaluate the Merge Readiness Gate. +16. Report final implementation status, changed files, tests, browser smoke result, residual risks, failed/passed gates, and manual review prompt. + +## Stop Conditions + +Stop the implementation loop when any of the following is true: + +- No confirmed in-scope findings remain. +- The same finding appears twice after attempted fixes. +- A required fix conflicts with the spec, plan, constitution, or repository architecture. +- A required fix would expand scope beyond the active spec. +- A required fix would require a risky unrelated refactor. +- A required fix depends on an unresolved product or architecture decision. +- Tests reveal an unrelated pre-existing failure that cannot be safely fixed inside the active spec. +- Browser smoke testing reveals an unrelated pre-existing UI/runtime failure that cannot be safely fixed inside the active spec. +- Three analysis/fix iterations have already been completed. +- The repository state is ambiguous enough that continuing would risk damaging architecture or data semantics. + +When stopping before full cleanliness, report exactly why the loop stopped and what remains. + +## Post-Implementation Analysis Prompt + +Use this prompt internally after implementation and after each fix iteration: + +```markdown +Du bist ein Senior Staff Software Engineer, Software Architect und Enterprise SaaS Reviewer. + +Analysiere die Implementierung der aktiven Spec streng repo-basiert. + +Ziel: +Prüfe, ob die Umsetzung vollständig, konsistent, getestet und constitution-konform ist. + +Prüfe gegen: +- spec.md +- plan.md +- tasks.md +- .specify/memory/constitution.md +- geänderte Anwendungscodes +- geänderte Tests +- Browser-Smoke-Test-Ergebnis, falls UI/user-facing Flows betroffen sind +- bestehende Repository-Patterns + +Wichtig: +- Keine Spekulation ohne Repo-Beleg. +- Keine Scope-Erweiterung. +- Keine neuen Produktideen als Pflicht-Fixes. +- Findings nach Blocker, High, Medium, Low gruppieren. +- Für jedes Finding konkrete Datei-/Code-Belege nennen. +- Für jedes Finding eine minimale Remediation nennen. +- Separat ausweisen, welche Findings innerhalb der aktiven Spec behoben werden müssen. +- Medium/Low Findings innerhalb der aktiven Spec ebenfalls zur Behebung markieren, wenn sie sicher und bounded sind. +- Bei UI-/Filament-/Livewire-Änderungen prüfen, ob ein Browser Smoke Test durchgeführt wurde und ob der getestete Operator-Flow wirklich funktioniert. +- Findings, die nicht behoben werden sollen, nur als Follow-up/Residual Risk ausweisen, wenn sie out of scope, risky refactor, unrelated existing debt, not reproducible oder durch eine offene Produkt-/Architekturentscheidung blockiert sind. +- Wenn keine bestätigten In-Scope Findings verbleiben, klare Implementierungsfreigabe geben. +``` + +## Task Completion Rules + +- Keep `tasks.md` aligned with actual implementation status. +- Check off tasks only after the implementation and test evidence exists. +- If a task is obsolete because repository truth proves a different path, update the task note with the reason instead of silently deleting it. +- If a task cannot be completed inside scope, leave it unchecked and report why. + +## Testing Rules + +- Add or update tests for all changed business behavior. +- Include RBAC and workspace/tenant isolation tests where relevant. +- Include OperationRun, audit, evidence, or result-truth tests where relevant. +- Prefer regression tests for every fixed Blocker or High finding. +- Add regression tests for Medium/Low findings when the behavior is important and testable without excessive churn. +- Do not weaken tests to pass the suite. +- Do not treat a green UI path as sufficient without backend or policy coverage when the behavior is security- or governance-relevant. + +## Browser Smoke Test Rules + +Apply these rules when the active spec changes Filament UI, Livewire interactions, navigation, forms, tables, actions, modals, dashboards, operation drilldowns, tenant/workspace context, or any user-facing flow. + +The browser smoke test should be narrow and focused. It is not a full E2E suite unless explicitly requested. + +Minimum smoke path: + +1. Open the relevant page or entry point. +2. Confirm the expected workspace/tenant context where relevant. +3. Confirm the changed or newly introduced UI element is visible. +4. Execute the primary action or interaction changed by the spec. +5. Confirm the expected result state, notification, redirect, table update, modal state, operation link, or drilldown. +6. Check for relevant console errors. +7. Check for failed network requests related to the tested flow. +8. Document the tested path in the final response. + +For TenantPilot/TenantAtlas, pay special attention to: + +- Filament actions and header actions +- Livewire polling, modals, validation, and actions +- workspace/tenant context preservation +- RBAC/capability-dependent action visibility +- OperationRun links and drilldown continuity +- audit/evidence/result/support-diagnostic drilldowns where relevant +- empty states, badges, labels, and decision guidance where relevant + +Browser smoke testing is required for UI/user-facing changes and optional for backend-only changes. + +Do not treat browser smoke success as proof that backend security, policies, domain logic, auditability, or workspace/tenant isolation are correct. Those still require automated tests or repo-based verification. + +## Failure Handling + +If an implementation step, test phase, browser smoke phase, or post-implementation analysis fails: + +1. Stop at the relevant gate or stop condition. +2. Report the failing command or phase. +3. Summarize the error. +4. Do not attempt unrelated implementation as a workaround. +5. Suggest the smallest safe next action. + +If the branch or working tree state is unsafe: + +1. Stop before implementation changes. +2. Report the current branch and relevant uncommitted files. +3. Ask the user to commit, stash, or move to a clean worktree. + +## Final Response Requirements + +Respond with: + +1. Active spec directory +2. Summary of implemented changes +3. Tests/checks run and their results +4. Browser smoke test result, tested path, or not-applicable reason +5. Quality gates passed/failed and number of analysis/fix iterations performed +6. Remaining in-scope findings, if any +7. Residual risks and follow-up candidates, if relevant +8. Files changed +9. Explicit statement whether the Merge Readiness Gate passed and whether the implementation is ready for manual review/merge + +Keep the final response concise, but include enough detail for the user to continue immediately. + +## Manual Review Prompt + +Provide a ready-to-copy prompt like this, adapted to the active spec number and slug: + +```markdown +Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer. + +Führe eine finale manuelle Review der implementierten Spec `-` streng repo-basiert durch. + +Ziel: +Prüfe, ob die Implementierung nach dem Agenten-Loop wirklich merge-ready ist. + +Wichtig: +- Keine Implementierung. +- Keine Codeänderungen. +- Keine Scope-Erweiterung. +- Prüfe gegen spec.md, plan.md, tasks.md und constitution.md. +- Prüfe die geänderten Dateien, Tests, Browser-Smoke-Test-Ergebnis, RBAC, Workspace-/Tenant-Isolation, Auditability, UX und OperationRun-Semantik, soweit relevant. +- Benenne nur konkrete Findings mit Repo-Beleg. +- Gib am Ende eine klare Entscheidung: Merge-ready, merge-ready with notes, oder not merge-ready. +``` + +## Example Invocation + +User: + +```text +Nutze den Skill spec-kit-implementation-loop. +Implementiere die aktive Spec. +Danach Tests ausführen, Browser Smoke Test falls UI/user-facing betroffen ist, Post-Implementation Analyse durchführen und alle bestätigten In-Scope Findings unabhängig von Severity beheben, wenn safe und bounded. +Wiederhole test + browser smoke + analysis + fix bis keine In-Scope Findings mehr offen sind oder eine Stop Condition greift. +``` + +Expected behavior: + +1. Inspect active Spec Kit context, constitution, spec, plan, tasks, relevant code, and relevant tests. +2. Evaluate the Spec Readiness Gate and Implementation Scope Gate. +3. Implement only the active spec scope. +4. Run targeted tests and relevant checks. +5. Evaluate the Test Gate. +6. Run and evaluate Browser Smoke Test when UI/user-facing flows are affected. +7. Run post-implementation analysis. +8. Fix all confirmed in-scope findings regardless of severity when safe and bounded. +9. Repeat test + browser smoke + analysis + fix loop up to the stop conditions. +10. Evaluate the Merge Readiness Gate. +11. Report final status, changed files, tests, browser smoke result, residual risks, gates, and manual review prompt. +``` \ No newline at end of file diff --git a/.github/skills/spec-kit-next-best-prep/SKILL.md b/.github/skills/spec-kit-next-best-prep/SKILL.md new file mode 100644 index 00000000..2294b93c --- /dev/null +++ b/.github/skills/spec-kit-next-best-prep/SKILL.md @@ -0,0 +1,562 @@ +--- +name: spec-kit-next-best-prep +description: Select the next suitable TenantPilot/TenantAtlas spec candidate from roadmap/spec-candidates, run the repository's Spec Kit preparation flow, create or update spec.md/plan.md/tasks.md, run preparation analysis, fix preparation-artifact issues only, and stop before application implementation. +--- + +# Skill: Spec Kit Next-Best Preparation + +## Purpose + +Use this skill to prepare the next implementation-ready Spec Kit package for TenantPilot/TenantAtlas without implementing application code. + +This skill supports preparation only: + +1. Select or scope the next suitable feature from roadmap/spec-candidates. +2. Run the repository's real Spec Kit preparation workflow where available. +3. Create or update `spec.md`, `plan.md`, and `tasks.md`. +4. Run preparation `analyze` when supported. +5. Fix preparation-artifact issues only. +6. Evaluate preparation quality gates. +7. Stop before application implementation. + +The intended workflow is: + +```text +roadmap / spec-candidates / feature idea +→ inspect repo truth, constitution, roadmap, spec candidates, existing specs, and relevant code +→ select the next suitable candidate or scope the provided idea +→ run Spec Kit specify/plan/tasks/analyze where available +→ create or update spec.md + plan.md + tasks.md +→ fix preparation-artifact issues only +→ evaluate Candidate Selection Gate and Spec Readiness Gate +→ final preparation report +→ explicit implementation step later +``` + +## When to Use + +Use this skill when the user asks to: + +- select the next best spec candidate from `docs/product/spec-candidates.md` and roadmap sources +- turn a feature idea, roadmap item, or candidate into `spec.md`, `plan.md`, and `tasks.md` +- prepare Spec Kit artifacts in one pass +- run specify/plan/tasks/analyze without implementation +- fix preparation analysis issues in Spec Kit artifacts only +- prepare a feature package for a later implementation skill + +Typical user prompts: + +```text +Nimm den nächsten sinnvollen Spec Candidate aus Roadmap/spec-candidates und mach spec, plan und tasks. +``` + +```text +Mach daraus spec, plan und tasks in einem Rutsch, aber noch nicht implementieren. +``` + +```text +Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec und führe specify, plan, tasks und analyze aus. +``` + +```text +Behebe alle analyze-Issues in den Spec-Kit-Artefakten. Keine Application-Implementierung. +``` + +## Hard Rules + +- Work strictly repo-based. +- This is a preparation-only skill. +- Do not implement application code. +- Do not modify production code. +- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, routes, views, tests, or runtime behavior. +- Use the repository's actual Spec Kit workflow, scripts, templates, branch naming rules, and generated paths when available. +- Do not manually invent spec numbers, branch names, or spec paths if Spec Kit provides a script or command for that. +- Do not bypass Spec Kit branch mechanics. +- Create or update only Spec Kit preparation artifacts unless repository conventions require additional documentation artifacts. +- Do not expand scope beyond the selected feature, `spec.md`, `plan.md`, and `tasks.md`. +- Do not silently add roadmap features, adjacent UX rewrites, speculative architecture, or unrelated refactors. +- Follow the repository constitution and existing Spec Kit conventions. +- Preserve TenantPilot/TenantAtlas terminology. +- Prefer small, reviewable, implementation-ready specs over broad rewrites. +- Treat repository truth as authoritative over assumptions. +- If repository truth conflicts with the user-provided draft or candidate wording, keep repository truth and document the deviation. +- Fix only confirmed preparation-artifact findings from Spec Kit preparation analysis. +- Do not leave preparation findings open silently. If they are not fixed, document exactly why. +- Do not run destructive commands. +- Do not force checkout, reset, stash, rebase, merge, or delete branches. +- Do not overwrite existing specs. +- Do not move from preparation to an implementation step inside this skill. + +## Required Inputs + +The user should provide at least one of: + +- feature title and short goal +- full spec candidate +- roadmap item +- rough problem statement +- UX or architecture improvement idea +- instruction to choose the next best candidate from roadmap/spec-candidates + +If the input is incomplete, proceed with the smallest reasonable interpretation and document assumptions. + +If no suitable candidate can be selected safely, stop and report why. + +## Required Repository Checks + +Always check: + +1. `.specify/memory/constitution.md` +2. `.specify/templates/` +3. `.specify/scripts/` +4. existing Spec Kit command usage or repository instructions, if present +5. current branch and git status +6. `specs/` +7. `docs/product/spec-candidates.md` +8. relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present +9. nearby existing specs with related terminology or scope +10. application code only as needed to avoid wrong naming, wrong architecture, duplicate concepts, impossible tasks, duplicated specs, or already-completed candidates + +Do not edit application code. + +## Git and Branch Safety + +Before running any Spec Kit command: + +1. Check the current branch. +2. Check whether the working tree is clean. +3. If there are unrelated uncommitted changes, stop and report them. Do not continue. +4. If the working tree only contains user-intended planning edits for this operation, continue cautiously. +5. Let Spec Kit create or switch to the correct feature branch when that is how the repository workflow works. +6. Do not force checkout, reset, stash, rebase, merge, or delete branches. +7. Do not overwrite existing specs. + +If the repo requires an explicit branch creation script for `specify`, use that script rather than manually creating the branch. + +## Quality Gates + +### Gate 1: Candidate Selection Gate + +Required before creating a new spec from roadmap/spec-candidates. + +Pass criteria: + +- The selected candidate exists in roadmap/spec-candidate material or is directly provided by the user. +- The selected candidate is not already covered by an existing active or completed spec. +- The selected candidate aligns with current roadmap priorities or explicitly documented product direction. +- The candidate can be scoped as a small, reviewable, implementation-ready slice. +- Major adjacent concerns are listed as follow-up candidates instead of being hidden inside the primary scope. + +Fail behavior: + +- If no candidate satisfies the gate, stop and report the top candidates plus the reason none is ready. +- Do not invent a new roadmap direction to force progress. + +### Gate 2: Spec Readiness Gate + +Required before reporting that the package is ready for implementation. + +Pass criteria: + +- `spec.md`, `plan.md`, and `tasks.md` exist. +- The spec has clear problem statement, user value, functional requirements, out-of-scope boundaries, acceptance criteria, assumptions, and risks. +- The plan identifies likely affected repo surfaces and does not contradict repository architecture. +- The tasks are small, ordered, verifiable, and include test/validation tasks. +- RBAC, workspace/tenant isolation, auditability, OperationRun semantics, evidence/result-truth, and UX requirements are addressed where relevant. +- No open question blocks safe implementation. +- The scope is small enough for a bounded implementation loop in a later implementation skill. +- Required checklist artifacts exist when the constitution requires them. + +Fail behavior: + +- Fix preparation-artifact issues when they are safe and bounded. +- If readiness cannot be achieved without implementation or unresolved product decisions, stop and report the gap. +- Do not compensate for an unclear spec by inventing implementation scope. + +## Candidate Selection Rules + +When the user asks for the next best spec from roadmap/spec-candidates: + +- Read `docs/product/spec-candidates.md`. +- Read relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present. +- Check existing specs to avoid duplicates. +- Prefer candidates that align with current roadmap priorities, platform foundations, enterprise UX, RBAC/isolation, auditability, observability, and governance workflow maturity. +- Prefer candidates that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers. +- Prefer small, implementation-ready slices over broad platform rewrites. +- If multiple candidates are plausible, choose one primary candidate and document why it was selected. +- Add non-selected relevant candidates as follow-up spec candidates, not hidden scope. +- Do not invent a candidate if existing roadmap/spec-candidate material provides a suitable one. +- Do not pick a spec only because it is listed first. +- Evaluate the Candidate Selection Gate before creating the spec directory. + +Evaluate candidates using these criteria: + +1. **Roadmap Fit**: Does it support the current roadmap sequence or unlock the next roadmap layer? +2. **Foundation Value**: Does it strengthen reusable platform foundations such as RBAC, isolation, auditability, evidence, OperationRun observability, provider boundaries, vocabulary, baseline/control/finding semantics, or enterprise UX patterns? +3. **Dependency Unblocking**: Does it make future specs smaller, safer, or more consistent? +4. **Scope Size**: Can it be implemented as a narrow, testable slice? +5. **Repo Readiness**: Does the repo already have enough structure to implement the next slice safely? +6. **Risk Reduction**: Does it reduce current architectural or product risk? +7. **User/Product Value**: Does it produce visible operator value or make the platform more sellable without heavy scope? + +## Required Selection Output Before Spec Kit Execution + +Before running the Spec Kit flow, identify: + +- selected candidate title +- source location in roadmap/spec-candidates +- why it was selected +- why close alternatives were deferred +- roadmap relationship +- smallest viable implementation slice +- proposed concise feature description to feed into `specify` + +The feature description must be product- and behavior-oriented. It should not be a low-level implementation plan. + +## Spec Kit Preparation Flow + +### Step 1: Determine the repository's Spec Kit command pattern + +Inspect repository instructions and scripts to identify how this repo expects Spec Kit to be run. + +Common locations to inspect: + +```text +.specify/scripts/ +.specify/templates/ +.specify/memory/constitution.md +.github/prompts/ +.github/skills/ +README.md +specs/ +``` + +Use the repo-specific mechanism if present. + +### Step 2: Run `specify` + +Run the repository's `specify` flow using the selected candidate and the smallest viable slice. + +The `specify` input should include: + +- selected candidate title +- problem statement +- operator/user value +- roadmap relationship +- out-of-scope boundaries +- key acceptance criteria +- important enterprise constraints + +Let Spec Kit create the correct branch and spec location if that is the repo's configured behavior. + +### Step 3: Run `plan` + +Run the repository's `plan` flow for the generated spec. + +The `plan` input should keep the scope tight and should require repo-based alignment with: + +- constitution +- existing architecture +- workspace/tenant isolation +- RBAC +- OperationRun/observability where relevant +- evidence/snapshot/truth semantics where relevant +- Filament/Livewire conventions where relevant +- test strategy + +### Step 4: Run `tasks` + +Run the repository's `tasks` flow for the generated plan. + +The generated tasks must be: + +- ordered +- small +- testable +- grouped by phase +- limited to the selected scope +- suitable for later implementation or manual analysis before implementation + +### Step 5: Run preparation `analyze` + +Run the repository's `analyze` flow against the generated Spec Kit artifacts when the repository supports it. + +Analyze must check: + +- consistency between `spec.md`, `plan.md`, and `tasks.md` +- constitution alignment +- roadmap alignment +- whether the selected candidate was narrowed safely +- whether tasks are complete enough for implementation +- whether tasks accidentally require scope not described in the spec +- whether plan details conflict with repository architecture or terminology +- whether implementation risks are documented instead of silently ignored + +Do not use analyze as a trigger to implement application code. + +### Step 6: Fix preparation-artifact issues only + +If preparation analyze finds issues, fix only Spec Kit preparation artifacts such as: + +- `spec.md` +- `plan.md` +- `tasks.md` +- `checklists/requirements.md` or other generated Spec Kit metadata files, if the repository uses them + +Allowed fixes include: + +- clarify requirements +- tighten scope +- move out-of-scope work into follow-up candidates +- correct terminology +- add missing tasks +- remove tasks not backed by the spec +- align plan language with repository architecture +- add missing acceptance criteria or validation tasks +- add missing checklist artifacts required by the constitution + +Forbidden fixes include: + +- modifying application code +- creating migrations +- editing models, services, jobs, policies, Filament resources, Livewire components, tests, commands, routes, or views +- running implementation or test-fix loops +- changing runtime behavior + +### Step 7: Evaluate the Spec Readiness Gate + +After preparation analyze has passed or preparation-artifact issues have been fixed, evaluate the Spec Readiness Gate. + +Stop after this gate and do not implement. + +## Spec Directory Rules + +When creating a new spec directory, use the repository's Spec Kit-generated directory or path. + +If the repository does not provide a command for spec setup, use the next valid spec number and a kebab-case slug: + +```text +specs/-/ +``` + +The exact number must be derived from the current repository state and existing numbering conventions. + +Create or update preparation artifacts inside the selected spec directory: + +```text +specs/-/spec.md +specs/-/plan.md +specs/-/tasks.md +``` + +If the repository templates require additional preparation files, create them only when this is consistent with existing Spec Kit conventions. + +## `spec.md` Requirements + +The spec must be product- and behavior-oriented. It should avoid premature implementation detail unless needed for correctness. + +Include: + +- Feature title +- Problem statement +- Business/product value +- Primary users/operators +- User stories +- Functional requirements +- Non-functional requirements +- UX requirements +- RBAC/security requirements +- Auditability/observability requirements +- Data/truth-source requirements where relevant +- Out of scope +- Acceptance criteria +- Success criteria +- Risks +- Assumptions +- Open questions + +TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles: + +- workspace/tenant isolation +- capability-first RBAC +- auditability +- operation/result truth separation +- source-of-truth clarity +- calm enterprise operator UX +- progressive disclosure where useful +- no false positive calmness + +## `plan.md` Requirements + +The plan must be repo-aware and implementation-oriented, but it must not make code changes by itself. + +Include: + +- Technical approach +- Existing repository surfaces likely affected +- Domain/model implications +- UI/Filament implications +- Livewire implications where relevant +- OperationRun/monitoring implications where relevant +- RBAC/policy implications +- Audit/logging/evidence implications where relevant +- Data/migration implications where relevant +- Test strategy +- Rollout considerations +- Risk controls +- Implementation phases + +The plan should clearly distinguish where relevant: + +- execution truth +- artifact truth +- backup/snapshot truth +- recovery/evidence truth +- operator next action + +## `tasks.md` Requirements + +Tasks must be ordered, small, and verifiable. + +Include: + +- checkbox tasks +- phase grouping +- tests before or alongside implementation tasks where practical +- final validation tasks +- documentation/update tasks if needed +- explicit non-goals where useful + +Avoid vague tasks such as: + +```text +Clean up code +Refactor UI +Improve performance +Make it enterprise-ready +``` + +Prefer concrete tasks such as: + +```text +- [ ] Add a feature test covering workspace isolation for . +- [ ] Update to display . +- [ ] Add policy coverage for . +``` + +If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths. + +## Preparation Scope Control + +If the requested feature implies multiple independent concerns, create one primary spec for the smallest valuable slice and add a `Follow-up spec candidates` section. + +Examples of follow-up candidates: + +- assigned findings +- pending approvals +- personal work queue +- notification delivery settings +- evidence pack export hardening +- operation monitoring refinements +- autonomous governance decision surfaces + +Do not force all follow-up candidates into the primary spec. + +## Failure Handling + +If a Spec Kit command or preparation analyze phase fails: + +1. Stop at the relevant gate. +2. Report the failing command or phase. +3. Summarize the error. +4. Do not attempt implementation as a workaround. +5. Suggest the smallest safe next action. + +If the branch or working tree state is unsafe: + +1. Stop before running Spec Kit commands. +2. Report the current branch and relevant uncommitted files. +3. Ask the user to commit, stash, or move to a clean worktree. + +## Final Response Requirements + +Respond with: + +1. Selected candidate and why it was chosen +2. Why close alternatives were deferred +3. Current branch after Spec Kit execution, if changed +4. Generated spec path +5. Files created or updated by Spec Kit +6. Preparation analyze result summary +7. Preparation-artifact fixes applied after analyze +8. Assumptions made +9. Open questions, if any +10. Candidate Selection Gate result +11. Spec Readiness Gate result +12. Recommended next implementation prompt +13. Explicit statement that no application implementation was performed + +Keep the final response concise, but include enough detail for the user to continue immediately. + +## Manual Review and Next-Step Prompts + +Provide a ready-to-copy manual artifact review prompt like this, adapted to the generated spec branch/path: + +```markdown +Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer. + +Analysiere die neu erstellte Spec `` streng repo-basiert. + +Ziel: +Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar und constitution-konform sind. + +Wichtig: +- Keine Implementierung. +- Keine Codeänderungen. +- Keine Scope-Erweiterung. +- Prüfe nur gegen Repo-Wahrheit. +- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs. +- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor. +- Wenn alles passt, gib eine klare Implementierungsfreigabe. +``` + +Also provide a ready-to-copy implementation prompt for the separate implementation skill after analyze has passed or preparation-artifact issues have been fixed: + +```markdown +/spec-kit-implementation-loop + +Implementiere die vorbereitete Spec `` streng anhand von `tasks.md`. + +Danach Tests ausführen, Browser Smoke Test falls UI/user-facing betroffen ist, Post-Implementation Analyse durchführen und alle bestätigten In-Scope Findings unabhängig von Severity beheben, wenn safe und bounded. + +Wiederhole test + browser smoke + analysis + fix bis keine In-Scope Findings mehr offen sind oder eine Stop Condition greift. +``` + +## Example Invocation + +User: + +```text +Nutze den Skill spec-kit-next-best-prep. +Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec. +Führe danach GitHub Spec Kit specify, plan, tasks und analyze in einem Rutsch aus. +Behebe alle analyze-Issues in den Spec-Kit-Artefakten. +Keine Application-Implementierung. +``` + +Expected behavior: + +1. Inspect constitution, Spec Kit scripts/templates, specs, roadmap, and spec candidates. +2. Check branch and working tree safety. +3. Compare candidate suitability. +4. Select the next best candidate. +5. Evaluate the Candidate Selection Gate. +6. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup. +7. Run the repository's real Spec Kit `plan` flow. +8. Run the repository's real Spec Kit `tasks` flow. +9. Run the repository's real Spec Kit preparation `analyze` flow. +10. Fix analyze issues only in Spec Kit preparation artifacts. +11. Evaluate the Spec Readiness Gate. +12. Stop before application implementation. +13. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, gates, and next implementation prompt. +``` \ No newline at end of file diff --git a/apps/platform/app/Console/Commands/PruneProductUsageEventsCommand.php b/apps/platform/app/Console/Commands/PruneProductUsageEventsCommand.php new file mode 100644 index 00000000..281c8d82 --- /dev/null +++ b/apps/platform/app/Console/Commands/PruneProductUsageEventsCommand.php @@ -0,0 +1,42 @@ +option('days') ?: config('tenantpilot.product_usage_event_retention_days', 90)); + + if ($days < 1) { + $this->error('Retention days must be at least 1.'); + + return self::FAILURE; + } + + $cutoff = now()->subDays($days); + + $deleted = ProductUsageEvent::query() + ->where('occurred_at', '<', $cutoff) + ->delete(); + + $this->info("Deleted {$deleted} product usage event(s) older than {$days} days."); + + return self::SUCCESS; + } +} diff --git a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index 4bc07e48..f4f883ae 100644 --- a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -23,6 +23,8 @@ use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\RunDetailPolling; +use App\Support\ProductTelemetry\ProductTelemetryRecorder; +use App\Support\ProductTelemetry\ProductUsageEventCatalog; use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\RedactionIntegrity; use App\Support\RestoreSafety\RestoreSafetyCopy; @@ -328,6 +330,19 @@ private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, U operationRun: $this->run, ); + app(ProductTelemetryRecorder::class)->record( + eventName: ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED, + workspaceId: (int) $tenant->workspace_id, + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + subjectType: 'operation_run', + subjectId: (int) $this->run->getKey(), + metadata: [ + 'source_surface' => 'operation_run_viewer', + 'operation_type' => (string) $this->run->type, + ], + ); + $this->supportDiagnosticsAuditKeys[] = $auditKey; } diff --git a/apps/platform/app/Filament/Pages/TenantDashboard.php b/apps/platform/app/Filament/Pages/TenantDashboard.php index 2b6817f6..ac7f764a 100644 --- a/apps/platform/app/Filament/Pages/TenantDashboard.php +++ b/apps/platform/app/Filament/Pages/TenantDashboard.php @@ -16,6 +16,8 @@ use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Auth\CapabilityResolver; use App\Support\Auth\Capabilities; +use App\Support\ProductTelemetry\ProductTelemetryRecorder; +use App\Support\ProductTelemetry\ProductUsageEventCatalog; use App\Support\Rbac\UiEnforcement; use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder; use Filament\Actions\Action; @@ -156,6 +158,18 @@ private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, U actor: $user, ); + app(ProductTelemetryRecorder::class)->record( + eventName: ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED, + workspaceId: (int) $tenant->workspace_id, + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + subjectType: 'tenant', + subjectId: (int) $tenant->getKey(), + metadata: [ + 'source_surface' => 'tenant_dashboard', + ], + ); + $this->supportDiagnosticsAuditKeys[] = $auditKey; } } diff --git a/apps/platform/app/Filament/System/Pages/Dashboard.php b/apps/platform/app/Filament/System/Pages/Dashboard.php index 800d447e..664d5f19 100644 --- a/apps/platform/app/Filament/System/Pages/Dashboard.php +++ b/apps/platform/app/Filament/System/Pages/Dashboard.php @@ -6,6 +6,7 @@ use App\Filament\System\Widgets\ControlTowerHealthIndicator; use App\Filament\System\Widgets\ControlTowerKpis; +use App\Filament\System\Widgets\ProductTelemetryKpis; use App\Filament\System\Widgets\ControlTowerRecentFailures; use App\Filament\System\Widgets\ControlTowerTopOffenders; use App\Models\PlatformUser; @@ -61,9 +62,18 @@ public function getWidgets(): array { return [ ControlTowerHealthIndicator::class, - ControlTowerKpis::class, - ControlTowerTopOffenders::class, - ControlTowerRecentFailures::class, + new WidgetConfiguration(ControlTowerKpis::class, [ + 'window' => $this->window, + ]), + new WidgetConfiguration(ProductTelemetryKpis::class, [ + 'window' => $this->window, + ]), + new WidgetConfiguration(ControlTowerTopOffenders::class, [ + 'window' => $this->window, + ]), + new WidgetConfiguration(ControlTowerRecentFailures::class, [ + 'window' => $this->window, + ]), ]; } diff --git a/apps/platform/app/Filament/System/Widgets/ControlTowerKpis.php b/apps/platform/app/Filament/System/Widgets/ControlTowerKpis.php index af88f420..d44027d6 100644 --- a/apps/platform/app/Filament/System/Widgets/ControlTowerKpis.php +++ b/apps/platform/app/Filament/System/Widgets/ControlTowerKpis.php @@ -19,12 +19,14 @@ class ControlTowerKpis extends StatsOverviewWidget protected int|string|array $columnSpan = 'full'; + public ?string $window = null; + /** * @return array */ protected function getStats(): array { - $window = SystemConsoleWindow::fromNullable((string) request()->query('window')); + $window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window')); $start = $window->startAt(); $baseQuery = OperationRun::query()->where('created_at', '>=', $start); diff --git a/apps/platform/app/Filament/System/Widgets/ControlTowerRecentFailures.php b/apps/platform/app/Filament/System/Widgets/ControlTowerRecentFailures.php index f2d361df..7d8893df 100644 --- a/apps/platform/app/Filament/System/Widgets/ControlTowerRecentFailures.php +++ b/apps/platform/app/Filament/System/Widgets/ControlTowerRecentFailures.php @@ -21,12 +21,14 @@ class ControlTowerRecentFailures extends Widget protected string $view = 'filament.system.widgets.control-tower-recent-failures'; + public ?string $window = null; + /** * @return array */ protected function getViewData(): array { - $window = SystemConsoleWindow::fromNullable((string) request()->query('window')); + $window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window')); $start = $window->startAt(); /** @var Collection $runs */ diff --git a/apps/platform/app/Filament/System/Widgets/ControlTowerTopOffenders.php b/apps/platform/app/Filament/System/Widgets/ControlTowerTopOffenders.php index 51f27b3a..dd509536 100644 --- a/apps/platform/app/Filament/System/Widgets/ControlTowerTopOffenders.php +++ b/apps/platform/app/Filament/System/Widgets/ControlTowerTopOffenders.php @@ -23,12 +23,14 @@ class ControlTowerTopOffenders extends Widget protected string $view = 'filament.system.widgets.control-tower-top-offenders'; + public ?string $window = null; + /** * @return array */ protected function getViewData(): array { - $window = SystemConsoleWindow::fromNullable((string) request()->query('window')); + $window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window')); $start = $window->startAt(); /** @var Collection $grouped */ diff --git a/apps/platform/app/Filament/System/Widgets/ProductTelemetryKpis.php b/apps/platform/app/Filament/System/Widgets/ProductTelemetryKpis.php new file mode 100644 index 00000000..0327e290 --- /dev/null +++ b/apps/platform/app/Filament/System/Widgets/ProductTelemetryKpis.php @@ -0,0 +1,47 @@ + + */ + protected function getStats(): array + { + $window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window')); + $windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours'; + $summary = app(ProductTelemetrySummaryQuery::class)->summarize($window->startAt(), now()); + + $stats = [ + Stat::make('Active workspaces', $summary['active_workspaces']) + ->description($summary['total_events'] > 0 + ? sprintf('%d events in %s', $summary['total_events'], $windowLabel) + : sprintf('No telemetry recorded in %s.', $windowLabel)) + ->color($summary['active_workspaces'] > 0 ? 'primary' : 'gray'), + ]; + + foreach ($summary['families'] as $family) { + $stats[] = Stat::make($family['label'], $family['count']) + ->description($windowLabel) + ->color($family['count'] > 0 ? 'primary' : 'gray'); + } + + return $stats; + } +} \ No newline at end of file diff --git a/apps/platform/app/Models/ProductUsageEvent.php b/apps/platform/app/Models/ProductUsageEvent.php new file mode 100644 index 00000000..01b75e53 --- /dev/null +++ b/apps/platform/app/Models/ProductUsageEvent.php @@ -0,0 +1,55 @@ + */ + use HasFactory; + + protected $guarded = []; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'metadata' => 'array', + 'occurred_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class)->withTrashed(); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesReportService.php b/apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesReportService.php index 843283ef..39b7684a 100644 --- a/apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesReportService.php +++ b/apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesReportService.php @@ -9,6 +9,8 @@ use App\Models\Tenant; use App\Services\Graph\GraphClientInterface; use App\Services\Providers\MicrosoftGraphOptionsResolver; +use App\Support\ProductTelemetry\ProductTelemetryRecorder; +use App\Support\ProductTelemetry\ProductUsageEventCatalog; use Carbon\CarbonImmutable; use RuntimeException; @@ -18,6 +20,7 @@ public function __construct( private readonly GraphClientInterface $graphClient, private readonly HighPrivilegeRoleCatalog $catalog, private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver, + private readonly ProductTelemetryRecorder $productTelemetryRecorder, ) {} /** @@ -57,6 +60,8 @@ public function generate(Tenant $tenant, ?OperationRun $operationRun = null): En 'previous_fingerprint' => $latestReport?->fingerprint, ]); + $this->recordStoredReportTelemetry($report, $operationRun); + return new EntraAdminRolesReportResult( created: true, storedReportId: (int) $report->getKey(), @@ -192,4 +197,24 @@ private function resolvePrincipalType(array $principal): string default => 'unknown', }; } + + private function recordStoredReportTelemetry(StoredReport $report, ?OperationRun $operationRun): void + { + if (! $operationRun instanceof OperationRun || ! is_numeric($operationRun->user_id)) { + return; + } + + $this->productTelemetryRecorder->record( + eventName: ProductUsageEventCatalog::STORED_REPORT_CREATED, + workspaceId: (int) $report->workspace_id, + tenantId: (int) $report->tenant_id, + userId: (int) $operationRun->user_id, + subjectType: 'stored_report', + subjectId: (int) $report->getKey(), + metadata: [ + 'report_type' => $report->report_type, + ], + occurredAt: $report->created_at ?? now(), + ); + } } diff --git a/apps/platform/app/Services/Onboarding/OnboardingDraftMutationService.php b/apps/platform/app/Services/Onboarding/OnboardingDraftMutationService.php index c64f0e7d..24d403ed 100644 --- a/apps/platform/app/Services/Onboarding/OnboardingDraftMutationService.php +++ b/apps/platform/app/Services/Onboarding/OnboardingDraftMutationService.php @@ -179,5 +179,7 @@ private function persistDraft(TenantOnboardingSession $draft, bool $incrementVer $this->lifecycleService->applySnapshot($draft, false); $draft->save(); + + $this->lifecycleService->recordCompletedCheckpointTelemetryIfNeeded($draft); } } diff --git a/apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php b/apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php index ea68fde6..3207449e 100644 --- a/apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php +++ b/apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php @@ -15,12 +15,15 @@ use App\Support\OperationCatalog; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\ProductTelemetry\ProductTelemetryRecorder; +use App\Support\ProductTelemetry\ProductUsageEventCatalog; use App\Support\Verification\VerificationReportOverall; class OnboardingLifecycleService { public function __construct( private readonly TenantOperabilityService $tenantOperabilityService, + private readonly ProductTelemetryRecorder $productTelemetryRecorder, ) {} public function syncPersistedLifecycle(TenantOnboardingSession $draft, bool $incrementVersion = false): TenantOnboardingSession @@ -35,6 +38,7 @@ public function syncPersistedLifecycle(TenantOnboardingSession $draft, bool $inc if ($changed) { $freshDraft->save(); + $this->recordCompletedCheckpointTelemetryIfNeeded($freshDraft); } return $freshDraft->refresh(); @@ -94,6 +98,46 @@ public function applySnapshot(TenantOnboardingSession $draft, bool $incrementVer return $changed; } + public function recordCompletedCheckpointTelemetryIfNeeded(TenantOnboardingSession $draft): void + { + if (! $draft->wasChanged('last_completed_checkpoint')) { + return; + } + + $checkpoint = $draft->last_completed_checkpoint instanceof OnboardingCheckpoint + ? $draft->last_completed_checkpoint + : OnboardingCheckpoint::tryFrom((string) $draft->last_completed_checkpoint); + + if (! $checkpoint instanceof OnboardingCheckpoint) { + return; + } + + $workspaceId = (int) ($draft->workspace_id ?? 0); + $tenantId = (int) ($draft->tenant_id ?? 0); + $userId = (int) ($draft->updated_by_user_id ?? 0); + + if ($workspaceId <= 0 || $tenantId <= 0 || $userId <= 0) { + return; + } + + $occurredAt = $draft->updated_at ?? now(); + + $this->productTelemetryRecorder->record( + eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED, + workspaceId: $workspaceId, + tenantId: $tenantId, + userId: $userId, + subjectType: 'tenant_onboarding_session', + subjectId: (int) $draft->getKey(), + metadata: [ + 'checkpoint_key' => $checkpoint->value, + 'lifecycle_state' => $draft->lifecycleState()->value, + 'completed_at' => $occurredAt, + ], + occurredAt: $occurredAt, + ); + } + /** * @return array{ * lifecycle_state: OnboardingLifecycleState, diff --git a/apps/platform/app/Services/OperationRunService.php b/apps/platform/app/Services/OperationRunService.php index ef48a6af..892c0f79 100644 --- a/apps/platform/app/Services/OperationRunService.php +++ b/apps/platform/app/Services/OperationRunService.php @@ -23,6 +23,8 @@ use App\Support\OpsUx\BulkRunContext; use App\Support\OpsUx\RunFailureSanitizer; use App\Support\OpsUx\SummaryCountsNormalizer; +use App\Support\ProductTelemetry\ProductTelemetryRecorder; +use App\Support\ProductTelemetry\ProductUsageEventCatalog; use App\Support\Providers\ProviderReasonCodes; use App\Support\RbacReason; use App\Support\ReasonTranslation\NextStepOption; @@ -44,6 +46,7 @@ public function __construct( private readonly AuditRecorder $auditRecorder, private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver, private readonly ReasonTranslator $reasonTranslator, + private readonly ProductTelemetryRecorder $productTelemetryRecorder, ) {} public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool @@ -139,7 +142,7 @@ public function ensureRun( // Create new run (race-safe via partial unique index) try { - return OperationRun::create([ + $run = OperationRun::create([ 'workspace_id' => $workspaceId, 'tenant_id' => $tenant->id, 'user_id' => $initiator?->id, @@ -150,6 +153,10 @@ public function ensureRun( 'run_identity_hash' => $hash, 'context' => $inputs, ]); + + $this->recordOperationStartedTelemetry($run, $initiator); + + return $run; } catch (QueryException $e) { // Unique violation (active-run dedupe): // - PostgreSQL: 23505 @@ -205,7 +212,7 @@ public function ensureRunWithIdentity( // Create new run (race-safe via partial unique index) try { - return OperationRun::create([ + $run = OperationRun::create([ 'workspace_id' => $workspaceId, 'tenant_id' => $tenant->id, 'user_id' => $initiator?->id, @@ -216,6 +223,10 @@ public function ensureRunWithIdentity( 'run_identity_hash' => $hash, 'context' => $context, ]); + + $this->recordOperationStartedTelemetry($run, $initiator); + + return $run; } catch (QueryException $e) { // Unique violation (active-run dedupe): // - PostgreSQL: 23505 @@ -336,7 +347,7 @@ public function ensureRunWithIdentityStrict( } try { - return OperationRun::create([ + $run = OperationRun::create([ 'workspace_id' => $workspaceId, 'tenant_id' => $tenant->id, 'user_id' => $initiator?->id, @@ -347,6 +358,10 @@ public function ensureRunWithIdentityStrict( 'run_identity_hash' => $hash, 'context' => $context, ]); + + $this->recordOperationStartedTelemetry($run, $initiator); + + return $run; } catch (QueryException $e) { if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) { throw $e; @@ -1032,6 +1047,30 @@ private function normalizeExecutionContext(string $type, array $context, ?User $ return $context; } + private function recordOperationStartedTelemetry(OperationRun $run, ?User $initiator): void + { + if (! $initiator instanceof User) { + return; + } + + if (! is_numeric($run->workspace_id) || ! is_numeric($run->tenant_id)) { + return; + } + + $this->productTelemetryRecorder->record( + eventName: ProductUsageEventCatalog::OPERATIONS_STARTED, + workspaceId: (int) $run->workspace_id, + tenantId: (int) $run->tenant_id, + userId: (int) $initiator->getKey(), + subjectType: 'operation_run', + subjectId: (int) $run->getKey(), + metadata: [ + 'operation_type' => (string) $run->type, + ], + occurredAt: $run->created_at ?? now(), + ); + } + /** * Normalize inputs for stable identity hashing. * diff --git a/apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php b/apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php index c18a9dcf..55c87959 100644 --- a/apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php +++ b/apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php @@ -11,6 +11,8 @@ use App\Models\Tenant; use App\Services\Findings\FindingSlaPolicy; use App\Services\Findings\FindingWorkflowService; +use App\Support\ProductTelemetry\ProductTelemetryRecorder; +use App\Support\ProductTelemetry\ProductUsageEventCatalog; use Carbon\CarbonImmutable; /** @@ -22,6 +24,7 @@ final class PermissionPostureFindingGenerator implements FindingGeneratorContrac public function __construct( private readonly PostureScoreCalculator $scoreCalculator, private readonly FindingSlaPolicy $slaPolicy, + private readonly ProductTelemetryRecorder $productTelemetryRecorder, private readonly ?FindingWorkflowService $findingWorkflowService = null, ) {} @@ -94,6 +97,7 @@ public function generate(Tenant $tenant, array $permissionComparison, ?Operation $postureScore = $this->scoreCalculator->calculate($permissionComparison); $report = $this->createStoredReport($tenant, $permissionComparison, $permissions, $postureScore); + $this->recordStoredReportTelemetry($report, $operationRun); return new PostureResult( findingsCreated: $created, @@ -404,6 +408,26 @@ private function createStoredReport( ]); } + private function recordStoredReportTelemetry(StoredReport $report, ?OperationRun $operationRun): void + { + if (! $operationRun instanceof OperationRun || ! is_numeric($operationRun->user_id)) { + return; + } + + $this->productTelemetryRecorder->record( + eventName: ProductUsageEventCatalog::STORED_REPORT_CREATED, + workspaceId: (int) $report->workspace_id, + tenantId: (int) $report->tenant_id, + userId: (int) $operationRun->user_id, + subjectType: 'stored_report', + subjectId: (int) $report->getKey(), + metadata: [ + 'report_type' => $report->report_type, + ], + occurredAt: $report->created_at ?? now(), + ); + } + /** * @return array */ diff --git a/apps/platform/app/Services/ReviewPackService.php b/apps/platform/app/Services/ReviewPackService.php index 7278b741..40e1bb39 100644 --- a/apps/platform/app/Services/ReviewPackService.php +++ b/apps/platform/app/Services/ReviewPackService.php @@ -17,6 +17,8 @@ use App\Services\Evidence\EvidenceSnapshotResolver; use App\Support\Audit\AuditActionId; use App\Support\OperationRunType; +use App\Support\ProductTelemetry\ProductTelemetryRecorder; +use App\Support\ProductTelemetry\ProductUsageEventCatalog; use App\Support\ReviewPackStatus; use Illuminate\Support\Facades\URL; @@ -26,6 +28,7 @@ public function __construct( private OperationRunService $operationRunService, private EvidenceSnapshotResolver $snapshotResolver, private WorkspaceAuditLogger $auditLogger, + private ProductTelemetryRecorder $productTelemetryRecorder, ) {} /** @@ -51,7 +54,10 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie $fingerprint = $this->computeFingerprintForSnapshot($snapshot, $options); $existing = $this->findExistingPack($tenant, $fingerprint); + if ($existing instanceof ReviewPack) { + $this->recordReviewPackRequestTelemetry($existing, $user, 'tenant'); + return $existing; } @@ -70,6 +76,8 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie $queuedPack = $this->findPackForRun($tenant, $operationRun); if ($queuedPack instanceof ReviewPack) { + $this->recordReviewPackRequestTelemetry($queuedPack, $user, 'tenant'); + return $queuedPack; } } @@ -109,6 +117,8 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie ); }); + $this->recordReviewPackRequestTelemetry($reviewPack, $user, 'tenant'); + return $reviewPack; } @@ -134,6 +144,7 @@ public function generateFromReview(TenantReview $review, User $user, array $opti if ($existing instanceof ReviewPack) { $this->logReviewExport($review, $user, $existing, 'reused'); + $this->recordReviewPackRequestTelemetry($existing, $user, 'tenant_review'); return $existing; } @@ -155,6 +166,7 @@ public function generateFromReview(TenantReview $review, User $user, array $opti if ($queuedPack instanceof ReviewPack) { $this->logReviewExport($review, $user, $queuedPack, 'reused_active_run'); + $this->recordReviewPackRequestTelemetry($queuedPack, $user, 'tenant_review'); return $queuedPack; } @@ -198,6 +210,7 @@ public function generateFromReview(TenantReview $review, User $user, array $opti }); $this->logReviewExport($review, $user, $reviewPack, 'queued'); + $this->recordReviewPackRequestTelemetry($reviewPack, $user, 'tenant_review'); return $reviewPack; } @@ -226,6 +239,24 @@ public function generateDownloadUrl(ReviewPack $pack): string ); } + private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void + { + $this->productTelemetryRecorder->record( + eventName: ProductUsageEventCatalog::REVIEW_PACK_REQUESTED, + workspaceId: (int) $reviewPack->workspace_id, + tenantId: (int) $reviewPack->tenant_id, + userId: (int) $user->getKey(), + subjectType: 'review_pack', + subjectId: (int) $reviewPack->getKey(), + metadata: [ + 'source_surface' => $sourceSurface, + 'include_operations' => (bool) ($reviewPack->options['include_operations'] ?? false), + 'include_pii' => (bool) ($reviewPack->options['include_pii'] ?? false), + ], + occurredAt: $reviewPack->created_at ?? now(), + ); + } + /** * Find an existing ready, non-expired pack with the same fingerprint. */ diff --git a/apps/platform/app/Support/ProductTelemetry/ProductTelemetryRecorder.php b/apps/platform/app/Support/ProductTelemetry/ProductTelemetryRecorder.php new file mode 100644 index 00000000..bbd6b41d --- /dev/null +++ b/apps/platform/app/Support/ProductTelemetry/ProductTelemetryRecorder.php @@ -0,0 +1,128 @@ + $metadata + */ + public function record( + string $eventName, + int $workspaceId, + int $tenantId, + int $userId, + string $subjectType, + string|int $subjectId, + array $metadata = [], + ?DateTimeInterface $occurredAt = null, + ): ProductUsageEvent { + $occurredAt ??= now(); + + return ProductUsageEvent::query()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'event_name' => $eventName, + 'feature_area' => $this->catalog->featureArea($eventName), + 'subject_type' => $this->normalizeToken($subjectType, 'subject type'), + 'subject_id' => $this->normalizeSubjectId($subjectId), + 'metadata' => $this->normalizeMetadata($eventName, $metadata), + 'occurred_at' => Carbon::instance($occurredAt), + ]); + } + + /** + * @param array $metadata + * @return array + */ + private function normalizeMetadata(string $eventName, array $metadata): array + { + $allowedKeys = $this->catalog->allowedMetadataKeys($eventName); + $unknownKeys = array_values(array_diff(array_keys($metadata), $allowedKeys)); + + if ($unknownKeys !== []) { + $keys = implode(', ', $unknownKeys); + + throw new InvalidArgumentException("Unsupported telemetry metadata keys [{$keys}] for [{$eventName}]."); + } + + $normalized = []; + + foreach ($metadata as $key => $value) { + if ($value === null) { + continue; + } + + $normalized[$key] = $this->normalizeMetadataValue($value, $key); + } + + return $normalized; + } + + /** + * @return bool|int|string + */ + private function normalizeMetadataValue(mixed $value, string $key): bool|int|string + { + if ($value instanceof BackedEnum) { + return $this->normalizeToken((string) $value->value, $key); + } + + if ($value instanceof DateTimeInterface) { + return Carbon::instance($value)->toIso8601String(); + } + + if (is_bool($value) || is_int($value)) { + return $value; + } + + if (is_string($value)) { + return $this->normalizeToken($value, $key); + } + + throw new InvalidArgumentException("Telemetry metadata [{$key}] must be a bounded scalar, enum, or timestamp."); + } + + private function normalizeSubjectId(string|int $subjectId): string + { + if (is_int($subjectId)) { + return (string) $subjectId; + } + + return $this->normalizeToken($subjectId, 'subject id'); + } + + private function normalizeToken(string $value, string $field): string + { + $normalized = trim($value); + + if ($normalized === '') { + throw new InvalidArgumentException("Telemetry {$field} cannot be empty."); + } + + if (strlen($normalized) > 120) { + throw new InvalidArgumentException("Telemetry {$field} is too long."); + } + + if (! preg_match('/\A[a-zA-Z0-9._:+\/-]+\z/', $normalized)) { + throw new InvalidArgumentException("Telemetry {$field} must be a machine-safe token."); + } + + if (str_contains($normalized, '@')) { + throw new InvalidArgumentException("Telemetry {$field} cannot contain email-like data."); + } + + return $normalized; + } +} diff --git a/apps/platform/app/Support/ProductTelemetry/ProductTelemetrySummaryQuery.php b/apps/platform/app/Support/ProductTelemetry/ProductTelemetrySummaryQuery.php new file mode 100644 index 00000000..96c02378 --- /dev/null +++ b/apps/platform/app/Support/ProductTelemetry/ProductTelemetrySummaryQuery.php @@ -0,0 +1,65 @@ + + * } + */ + public function summarize(DateTimeInterface $startAt, ?DateTimeInterface $endAt = null): array + { + $startAt = Carbon::instance($startAt); + $endAt = Carbon::instance($endAt ?? now()); + + $baseQuery = ProductUsageEvent::query() + ->where('occurred_at', '>=', $startAt) + ->where('occurred_at', '<=', $endAt); + + /** @var array $counts */ + $counts = (clone $baseQuery) + ->selectRaw('event_name, COUNT(*) as aggregate') + ->groupBy('event_name') + ->pluck('aggregate', 'event_name') + ->map(static fn (mixed $count): int => (int) $count) + ->all(); + + $families = []; + $totalEvents = 0; + + foreach ($this->catalog->visibleFamilies() as $eventName => $label) { + $count = (int) ($counts[$eventName] ?? 0); + $totalEvents += $count; + + $families[$eventName] = [ + 'label' => $label, + 'count' => $count, + ]; + } + + return [ + 'window' => [ + 'start_at' => $startAt->toIso8601String(), + 'end_at' => $endAt->toIso8601String(), + ], + 'active_workspaces' => (clone $baseQuery) + ->distinct() + ->count('workspace_id'), + 'total_events' => $totalEvents, + 'families' => $families, + ]; + } +} diff --git a/apps/platform/app/Support/ProductTelemetry/ProductUsageEventCatalog.php b/apps/platform/app/Support/ProductTelemetry/ProductUsageEventCatalog.php new file mode 100644 index 00000000..a14183b2 --- /dev/null +++ b/apps/platform/app/Support/ProductTelemetry/ProductUsageEventCatalog.php @@ -0,0 +1,102 @@ + [ + 'feature_area' => 'onboarding', + 'label' => 'Onboarding checkpoints', + 'metadata_keys' => ['checkpoint_key', 'lifecycle_state', 'completed_at'], + ], + self::SUPPORT_DIAGNOSTICS_OPENED => [ + 'feature_area' => 'support_diagnostics', + 'label' => 'Support diagnostics', + 'metadata_keys' => ['source_surface', 'operation_type'], + ], + self::OPERATIONS_STARTED => [ + 'feature_area' => 'operations', + 'label' => 'Operations started', + 'metadata_keys' => ['operation_type'], + ], + self::STORED_REPORT_CREATED => [ + 'feature_area' => 'stored_reports', + 'label' => 'Stored reports', + 'metadata_keys' => ['report_type'], + ], + self::REVIEW_PACK_REQUESTED => [ + 'feature_area' => 'review_pack', + 'label' => 'Review packs requested', + 'metadata_keys' => ['source_surface', 'include_operations', 'include_pii'], + ], + ]; + + /** + * @return list + */ + public function names(): array + { + return array_keys(self::DEFINITIONS); + } + + /** + * @return array{feature_area: string, label: string, metadata_keys: list} + */ + public function definition(string $eventName): array + { + $definition = self::DEFINITIONS[$eventName] ?? null; + + if (! is_array($definition)) { + throw new InvalidArgumentException("Unknown product telemetry event [{$eventName}]."); + } + + return $definition; + } + + public function featureArea(string $eventName): string + { + return $this->definition($eventName)['feature_area']; + } + + public function label(string $eventName): string + { + return $this->definition($eventName)['label']; + } + + /** + * @return list + */ + public function allowedMetadataKeys(string $eventName): array + { + return $this->definition($eventName)['metadata_keys']; + } + + /** + * @return array + */ + public function visibleFamilies(): array + { + $families = []; + + foreach ($this->names() as $eventName) { + $families[$eventName] = $this->label($eventName); + } + + return $families; + } +} diff --git a/apps/platform/config/tenantpilot.php b/apps/platform/config/tenantpilot.php index d4ef8f1c..89e1b87b 100644 --- a/apps/platform/config/tenantpilot.php +++ b/apps/platform/config/tenantpilot.php @@ -571,6 +571,8 @@ 'retention_days' => (int) env('TENANTPILOT_STORED_REPORTS_RETENTION_DAYS', 90), ], + 'product_usage_event_retention_days' => (int) env('TENANTPILOT_PRODUCT_USAGE_EVENT_RETENTION_DAYS', 90), + 'display' => [ 'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false), 'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000), diff --git a/apps/platform/database/factories/ProductUsageEventFactory.php b/apps/platform/database/factories/ProductUsageEventFactory.php new file mode 100644 index 00000000..54162c64 --- /dev/null +++ b/apps/platform/database/factories/ProductUsageEventFactory.php @@ -0,0 +1,72 @@ + + */ +class ProductUsageEventFactory extends Factory +{ + protected $model = ProductUsageEvent::class; + + /** + * @return array + */ + public function definition(): array + { + $catalog = new ProductUsageEventCatalog(); + $eventName = ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED; + + return [ + 'tenant_id' => Tenant::factory()->for(Workspace::factory()), + 'workspace_id' => function (array $attributes): int { + $tenantId = $attributes['tenant_id'] ?? null; + + if (! is_numeric($tenantId)) { + return (int) Workspace::factory()->create()->getKey(); + } + + $tenant = Tenant::query()->whereKey((int) $tenantId)->first(); + + if (! $tenant instanceof Tenant || $tenant->workspace_id === null) { + return (int) Workspace::factory()->create()->getKey(); + } + + return (int) $tenant->workspace_id; + }, + 'user_id' => User::factory(), + 'event_name' => $eventName, + 'feature_area' => $catalog->featureArea($eventName), + 'subject_type' => 'tenant_onboarding_session', + 'subject_id' => (string) fake()->numberBetween(1, 999999), + 'metadata' => [ + 'checkpoint_key' => 'tenant_connected', + 'lifecycle_state' => 'completed', + ], + 'occurred_at' => now(), + ]; + } + + /** + * @param array $metadata + */ + public function forEvent(string $eventName, array $metadata = []): static + { + $catalog = new ProductUsageEventCatalog(); + + return $this->state(fn (): array => [ + 'event_name' => $eventName, + 'feature_area' => $catalog->featureArea($eventName), + 'metadata' => $metadata, + ]); + } +} diff --git a/apps/platform/database/migrations/2026_04_26_194038_create_product_usage_events_table.php b/apps/platform/database/migrations/2026_04_26_194038_create_product_usage_events_table.php new file mode 100644 index 00000000..298f95e4 --- /dev/null +++ b/apps/platform/database/migrations/2026_04_26_194038_create_product_usage_events_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('event_name', 120); + $table->string('feature_area', 60); + $table->string('subject_type', 120); + $table->string('subject_id', 120); + $table->jsonb('metadata')->default('{}'); + $table->timestampTz('occurred_at'); + $table->timestamps(); + + $table->index(['workspace_id', 'tenant_id', 'occurred_at']); + $table->index(['event_name', 'occurred_at']); + $table->index(['subject_type', 'subject_id', 'occurred_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_usage_events'); + } +}; diff --git a/apps/platform/routes/console.php b/apps/platform/routes/console.php index 7268dab2..8434e4a5 100644 --- a/apps/platform/routes/console.php +++ b/apps/platform/routes/console.php @@ -39,6 +39,11 @@ ->name('stored-reports:prune') ->withoutOverlapping(); +Schedule::command('tenantpilot:product-usage:prune') + ->daily() + ->name('tenantpilot:product-usage:prune') + ->withoutOverlapping(); + Schedule::command('tenantpilot:review-pack:prune') ->daily() ->name('tenantpilot:review-pack:prune') diff --git a/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesReportServiceTest.php b/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesReportServiceTest.php index f9b7d39f..038d09fb 100644 --- a/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesReportServiceTest.php +++ b/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesReportServiceTest.php @@ -146,6 +146,7 @@ function buildReportService(GraphClientInterface $graphMock): EntraAdminRolesRep graphClient: $graphMock, catalog: new HighPrivilegeRoleCatalog, graphOptionsResolver: app(MicrosoftGraphOptionsResolver::class), + productTelemetryRecorder: app(\App\Support\ProductTelemetry\ProductTelemetryRecorder::class), ); } diff --git a/apps/platform/tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php b/apps/platform/tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php index 76a7d3d1..b02f268b 100644 --- a/apps/platform/tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php +++ b/apps/platform/tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php @@ -28,6 +28,7 @@ function buildScanReportService(GraphClientInterface $graphClient): EntraAdminRo $graphClient, new HighPrivilegeRoleCatalog, app(MicrosoftGraphOptionsResolver::class), + app(\App\Support\ProductTelemetry\ProductTelemetryRecorder::class), ); } diff --git a/apps/platform/tests/Feature/Onboarding/ProductTelemetryOnboardingCaptureTest.php b/apps/platform/tests/Feature/Onboarding/ProductTelemetryOnboardingCaptureTest.php new file mode 100644 index 00000000..24968855 --- /dev/null +++ b/apps/platform/tests/Feature/Onboarding/ProductTelemetryOnboardingCaptureTest.php @@ -0,0 +1,99 @@ +create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $verificationRun = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + ]); + + $draft = createOnboardingDraft([ + 'workspace' => $tenant->workspace, + 'tenant' => $tenant, + 'started_by' => $user, + 'updated_by' => $user, + 'current_step' => 'verify', + 'state' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'tenant_name' => (string) $tenant->name, + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_operation_run_id' => (int) $verificationRun->getKey(), + ], + ]); + + app(OnboardingDraftMutationService::class)->mutate( + draft: $draft, + actor: $user, + mutator: static function (): void {}, + ); + + $event = ProductUsageEvent::query()->sole(); + $serializedEvent = json_encode($event->toArray(), JSON_THROW_ON_ERROR); + + expect($event->event_name)->toBe(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED) + ->and($event->workspace_id)->toBe((int) $tenant->workspace_id) + ->and($event->tenant_id)->toBe((int) $tenant->getKey()) + ->and($event->user_id)->toBe((int) $user->getKey()) + ->and($event->subject_type)->toBe('tenant_onboarding_session') + ->and($event->subject_id)->toBe((string) $draft->getKey()) + ->and($event->metadata['checkpoint_key'] ?? null)->toBe(OnboardingCheckpoint::VerifyAccess->value) + ->and($event->metadata['lifecycle_state'] ?? null)->toBe(OnboardingLifecycleState::ReadyForActivation->value) + ->and($event->metadata['completed_at'] ?? null)->not->toBeNull() + ->and($serializedEvent)->not->toContain('@') + ->and($serializedEvent)->not->toContain((string) $tenant->name) + ->and($serializedEvent)->not->toContain((string) ($draft->state['tenant_name'] ?? '')); +}); + +it('does not record onboarding telemetry for pre-tenant drafts', function (): void { + $workspace = \App\Models\Workspace::factory()->create(); + $user = \App\Models\User::factory()->create(); + + $draft = createOnboardingDraft([ + 'workspace' => $workspace, + 'started_by' => $user, + 'updated_by' => $user, + 'current_step' => 'identify', + 'state' => [ + 'entra_tenant_id' => fake()->uuid(), + 'tenant_name' => 'Pre Tenant Draft', + ], + ]); + + app(OnboardingDraftMutationService::class)->mutate( + draft: $draft, + actor: $user, + mutator: static function (): void {}, + ); + + expect(ProductUsageEvent::query()->count())->toBe(0); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/Operations/ProductTelemetryOperationStartCaptureTest.php b/apps/platform/tests/Feature/Operations/ProductTelemetryOperationStartCaptureTest.php new file mode 100644 index 00000000..cea28824 --- /dev/null +++ b/apps/platform/tests/Feature/Operations/ProductTelemetryOperationStartCaptureTest.php @@ -0,0 +1,73 @@ +create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + $service = app(OperationRunService::class); + + $run = $service->ensureRun( + tenant: $tenant, + type: OperationRunType::ReviewPackGenerate->value, + inputs: [ + 'include_pii' => true, + 'include_operations' => true, + ], + initiator: $user, + ); + + $sameRun = $service->ensureRun( + tenant: $tenant, + type: OperationRunType::ReviewPackGenerate->value, + inputs: [ + 'include_pii' => true, + 'include_operations' => true, + ], + initiator: $user, + ); + + expect($sameRun->getKey())->toBe($run->getKey()) + ->and(ProductUsageEvent::query()->count())->toBe(1); + + $event = ProductUsageEvent::query()->sole(); + $serializedEvent = json_encode($event->toArray(), JSON_THROW_ON_ERROR); + + expect($event->event_name)->toBe(ProductUsageEventCatalog::OPERATIONS_STARTED) + ->and($event->tenant_id)->toBe((int) $tenant->getKey()) + ->and($event->workspace_id)->toBe((int) $tenant->workspace_id) + ->and($event->user_id)->toBe((int) $user->getKey()) + ->and($event->subject_type)->toBe('operation_run') + ->and($event->subject_id)->toBe((string) $run->getKey()) + ->and($event->metadata)->toBe([ + 'operation_type' => OperationRunType::ReviewPackGenerate->value, + ]) + ->and($serializedEvent)->not->toContain('@') + ->and($serializedEvent)->not->toContain($user->name) + ->and($serializedEvent)->not->toContain((string) $tenant->name); +}); + +it('does not record telemetry for system-initiated operation starts', function (): void { + $tenant = Tenant::factory()->create(); + + app(OperationRunService::class)->ensureRun( + tenant: $tenant, + type: OperationRunType::ReviewPackGenerate->value, + inputs: [ + 'include_pii' => true, + ], + initiator: null, + ); + + expect(ProductUsageEvent::query()->count())->toBe(0); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/Reports/ProductTelemetryReportCaptureTest.php b/apps/platform/tests/Feature/Reports/ProductTelemetryReportCaptureTest.php new file mode 100644 index 00000000..efe30435 --- /dev/null +++ b/apps/platform/tests/Feature/Reports/ProductTelemetryReportCaptureTest.php @@ -0,0 +1,200 @@ + 'def-ga-001', + 'displayName' => 'Global Administrator', + 'templateId' => '62e90394-69f5-4237-9190-012177145e10', + 'isBuiltIn' => true, + ]]; +} + +function productTelemetryRoleAssignments(): array +{ + return [[ + 'id' => 'assign-1', + 'roleDefinitionId' => 'def-ga-001', + 'principalId' => 'user-aaa', + 'directoryScopeId' => '/', + 'principal' => [ + '@odata.type' => '#microsoft.graph.user', + 'displayName' => 'Alice Admin', + ], + ]]; +} + +function buildTelemetryGraphMock(): GraphClientInterface +{ + return new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return match ($policyType) { + 'entraRoleDefinitions' => new GraphResponse(success: true, data: productTelemetryRoleDefinitions(), status: 200), + 'entraRoleAssignments' => new GraphResponse(success: true, data: productTelemetryRoleAssignments(), status: 200), + default => new GraphResponse(success: false, status: 404, errors: ['Unknown type']), + }; + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(success: false, status: 501); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(success: false, status: 501); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(success: false, status: 501); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(success: false, status: 501); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(success: false, status: 501); + } + }; +} + +function buildTelemetryReportService(): EntraAdminRolesReportService +{ + return new EntraAdminRolesReportService( + graphClient: buildTelemetryGraphMock(), + catalog: new HighPrivilegeRoleCatalog, + graphOptionsResolver: app(MicrosoftGraphOptionsResolver::class), + productTelemetryRecorder: app(\App\Support\ProductTelemetry\ProductTelemetryRecorder::class), + ); +} + +it('records telemetry when a user-initiated Entra admin roles report is created', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + ensureDefaultProviderConnection($tenant); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => OperationRunType::InventorySync->value, + ]); + + $result = buildTelemetryReportService()->generate($tenant, $run); + + $event = ProductUsageEvent::query()->sole(); + $serializedEvent = json_encode($event->toArray(), JSON_THROW_ON_ERROR); + + expect($result->created)->toBeTrue() + ->and($event->event_name)->toBe(ProductUsageEventCatalog::STORED_REPORT_CREATED) + ->and($event->subject_type)->toBe('stored_report') + ->and($event->subject_id)->toBe((string) $result->storedReportId) + ->and($event->metadata)->toBe([ + 'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES, + ]) + ->and($serializedEvent)->not->toContain('@') + ->and($serializedEvent)->not->toContain('Alice Admin') + ->and($serializedEvent)->not->toContain((string) $tenant->name); +}); + +it('records telemetry when a user-initiated permission posture report is created', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => OperationRunType::InventorySync->value, + ]); + + $result = app(PermissionPostureFindingGenerator::class)->generate($tenant, [ + 'overall_status' => 'missing', + 'permissions' => [ + [ + 'key' => 'DeviceManagementApps.ReadWrite.All', + 'type' => 'application', + 'status' => 'missing', + 'features' => ['policy-sync'], + ], + ], + 'last_refreshed_at' => now()->toIso8601String(), + ], $run); + + $event = ProductUsageEvent::query()->sole(); + $serializedEvent = json_encode($event->toArray(), JSON_THROW_ON_ERROR); + + expect($event->event_name)->toBe(ProductUsageEventCatalog::STORED_REPORT_CREATED) + ->and($event->subject_type)->toBe('stored_report') + ->and($event->subject_id)->toBe((string) $result->storedReportId) + ->and($event->metadata)->toBe([ + 'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE, + ]) + ->and($serializedEvent)->not->toContain('@') + ->and($serializedEvent)->not->toContain('DeviceManagementApps.ReadWrite.All') + ->and($serializedEvent)->not->toContain((string) $tenant->name); +}); + +it('records telemetry when a user requests a review pack generation', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + seedTenantReviewEvidence($tenant); + Notification::fake(); + + $pack = app(ReviewPackService::class)->generate($tenant, $user, [ + 'include_pii' => true, + 'include_operations' => true, + ]); + + $event = ProductUsageEvent::query()->where('event_name', ProductUsageEventCatalog::REVIEW_PACK_REQUESTED)->sole(); + $serializedEvent = json_encode($event->toArray(), JSON_THROW_ON_ERROR); + + expect($pack->status)->toBe('queued') + ->and($event->subject_type)->toBe('review_pack') + ->and($event->subject_id)->toBe((string) $pack->getKey()) + ->and($event->metadata)->toBe([ + 'source_surface' => 'tenant', + 'include_operations' => true, + 'include_pii' => true, + ]) + ->and($serializedEvent)->not->toContain('@') + ->and($serializedEvent)->not->toContain((string) $tenant->name); + + $job = new GenerateReviewPackJob( + reviewPackId: (int) $pack->getKey(), + operationRunId: (int) $pack->operation_run_id, + ); + + app()->call([$job, 'handle']); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/SupportDiagnostics/ProductTelemetrySupportDiagnosticsCaptureTest.php b/apps/platform/tests/Feature/SupportDiagnostics/ProductTelemetrySupportDiagnosticsCaptureTest.php new file mode 100644 index 00000000..fc981896 --- /dev/null +++ b/apps/platform/tests/Feature/SupportDiagnostics/ProductTelemetrySupportDiagnosticsCaptureTest.php @@ -0,0 +1,98 @@ +actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + setTenantPanelContext($tenant); + + return Livewire::actingAs($user)->test(TenantDashboard::class); +} + +function operationDiagnosticsTelemetryComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable +{ + test()->actingAs($user); + Filament::setTenant(null, true); + session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id); + + return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]); +} + +it('records telemetry when support diagnostics are opened from the tenant dashboard', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + tenantDiagnosticsTelemetryComponent($user, $tenant) + ->mountAction('openSupportDiagnostics') + ->assertMountedActionModalSee('Support diagnostics'); + + $event = ProductUsageEvent::query()->sole(); + $serializedEvent = json_encode($event->toArray(), JSON_THROW_ON_ERROR); + + expect($event->event_name)->toBe(ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED) + ->and($event->tenant_id)->toBe((int) $tenant->getKey()) + ->and($event->workspace_id)->toBe((int) $tenant->workspace_id) + ->and($event->user_id)->toBe((int) $user->getKey()) + ->and($event->subject_type)->toBe('tenant') + ->and($event->subject_id)->toBe((string) $tenant->getKey()) + ->and($event->metadata)->toBe([ + 'source_surface' => 'tenant_dashboard', + ]) + ->and($serializedEvent)->not->toContain('@') + ->and($serializedEvent)->not->toContain($user->name) + ->and($serializedEvent)->not->toContain((string) $tenant->name); +}); + +it('records telemetry when support diagnostics are opened from the canonical operation viewer', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => OperationRunType::ReviewPackGenerate->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + ]); + + operationDiagnosticsTelemetryComponent($user, $run) + ->mountAction('openSupportDiagnostics') + ->assertMountedActionModalSee('Support diagnostics'); + + $event = ProductUsageEvent::query()->sole(); + $serializedEvent = json_encode($event->toArray(), JSON_THROW_ON_ERROR); + + expect($event->event_name)->toBe(ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED) + ->and($event->tenant_id)->toBe((int) $tenant->getKey()) + ->and($event->workspace_id)->toBe((int) $tenant->workspace_id) + ->and($event->user_id)->toBe((int) $user->getKey()) + ->and($event->subject_type)->toBe('operation_run') + ->and($event->subject_id)->toBe((string) $run->getKey()) + ->and($event->metadata)->toBe([ + 'source_surface' => 'operation_run_viewer', + 'operation_type' => OperationRunType::ReviewPackGenerate->value, + ]) + ->and($serializedEvent)->not->toContain('@') + ->and($serializedEvent)->not->toContain($user->name) + ->and($serializedEvent)->not->toContain((string) $tenant->name); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/System/ProductTelemetry/NoAdHocTelemetryBypassTest.php b/apps/platform/tests/Feature/System/ProductTelemetry/NoAdHocTelemetryBypassTest.php new file mode 100644 index 00000000..002812b4 --- /dev/null +++ b/apps/platform/tests/Feature/System/ProductTelemetry/NoAdHocTelemetryBypassTest.php @@ -0,0 +1,104 @@ +create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => OperationRunType::ReviewPackGenerate->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + ]); + + $platformUser = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::CONSOLE_VIEW, + ], + 'is_active' => true, + ]); + + test()->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + setTenantPanelContext($tenant); + + Livewire::actingAs($user)->test(TenantDashboard::class); + + Filament::setTenant(null, true); + session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id); + + Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]); + + test()->actingAs($platformUser, 'platform'); + Livewire::test(ProductTelemetryKpis::class); + + expect(ProductUsageEvent::query()->count())->toBe(0) + ->and(AuditLog::query()->count())->toBe(0); +}); + +it('keeps the system dashboard aggregate-only without exposing raw telemetry identifiers', function (): void { + $tenant = Tenant::factory()->create([ + 'name' => 'NoLeak Tenant Name', + ]); + + ProductUsageEvent::factory()->forEvent( + ProductUsageEventCatalog::REVIEW_PACK_REQUESTED, + [ + 'source_surface' => 'tenant', + 'include_operations' => true, + 'include_pii' => false, + ], + )->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'subject_type' => 'review_pack', + 'subject_id' => 'review-pack-raw-424242', + 'occurred_at' => now()->subHour(), + ]); + + $platformUser = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::CONSOLE_VIEW, + ], + 'is_active' => true, + ]); + + $this->actingAs($platformUser, 'platform') + ->get('/system?window=24h') + ->assertSuccessful() + ->assertSee('Product telemetry') + ->assertSee('Review packs requested') + ->assertDontSee('review-pack-raw-424242') + ->assertDontSee('NoLeak Tenant Name'); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryAuthorizationTest.php b/apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryAuthorizationTest.php new file mode 100644 index 00000000..d88af249 --- /dev/null +++ b/apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryAuthorizationTest.php @@ -0,0 +1,45 @@ +create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPS_VIEW, + PlatformCapabilities::RUNBOOKS_VIEW, + ], + 'is_active' => true, + ]); + + $this->actingAs($user, 'platform') + ->get('/system') + ->assertSuccessful() + ->assertSeeLivewire(ProductTelemetryKpis::class); +}); + +it('forbids aggregate telemetry for platform users outside the existing dashboard gate', function (): void { + $user = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + ], + 'is_active' => true, + ]); + + $this->actingAs($user, 'platform') + ->get('/system') + ->assertForbidden(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryDashboardWidgetTest.php b/apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryDashboardWidgetTest.php new file mode 100644 index 00000000..3f0d4605 --- /dev/null +++ b/apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryDashboardWidgetTest.php @@ -0,0 +1,230 @@ +setAccessible(true); + + return collect($method->invoke($component->instance())) + ->mapWithKeys(fn (Stat $stat): array => [ + (string) $stat->getLabel() => [ + 'value' => (string) $stat->getValue(), + 'description' => $stat->getDescription(), + ], + ]) + ->all(); +} + +function actingAsSystemConsoleUser(): PlatformUser +{ + $user = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::CONSOLE_VIEW, + ], + 'is_active' => true, + ]); + + test()->actingAs($user, 'platform'); + + return $user; +} + +function seedProductTelemetryEvent( + Tenant $tenant, + User $user, + string $eventName, + array $metadata, + string $subjectType, + int|string $subjectId, + CarbonImmutable $occurredAt, +): ProductUsageEvent { + return ProductUsageEvent::factory() + ->forEvent($eventName, $metadata) + ->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'subject_type' => $subjectType, + 'subject_id' => (string) $subjectId, + 'occurred_at' => $occurredAt, + ]); +} + +it('summarizes the five visible telemetry families and active workspaces for the selected window', function (): void { + actingAsSystemConsoleUser(); + + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + $userA = User::factory()->create(); + $userB = User::factory()->create(); + + seedProductTelemetryEvent( + tenant: $tenantA, + user: $userA, + eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED, + metadata: [ + 'checkpoint_key' => 'tenant_connected', + 'lifecycle_state' => 'ready', + 'completed_at' => CarbonImmutable::now()->subHour(), + ], + subjectType: 'tenant_onboarding_session', + subjectId: 101, + occurredAt: CarbonImmutable::now()->subHour(), + ); + + seedProductTelemetryEvent( + tenant: $tenantA, + user: $userA, + eventName: ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED, + metadata: [ + 'source_surface' => 'tenant_dashboard', + ], + subjectType: 'tenant', + subjectId: (int) $tenantA->getKey(), + occurredAt: CarbonImmutable::now()->subMinutes(90), + ); + + seedProductTelemetryEvent( + tenant: $tenantB, + user: $userB, + eventName: ProductUsageEventCatalog::OPERATIONS_STARTED, + metadata: [ + 'operation_type' => 'review_pack.generate', + ], + subjectType: 'operation_run', + subjectId: 202, + occurredAt: CarbonImmutable::now()->subHours(2), + ); + + seedProductTelemetryEvent( + tenant: $tenantB, + user: $userB, + eventName: ProductUsageEventCatalog::REVIEW_PACK_REQUESTED, + metadata: [ + 'source_surface' => 'tenant', + 'include_operations' => true, + 'include_pii' => false, + ], + subjectType: 'review_pack', + subjectId: 303, + occurredAt: CarbonImmutable::now()->subHours(3), + ); + + seedProductTelemetryEvent( + tenant: $tenantB, + user: $userB, + eventName: ProductUsageEventCatalog::STORED_REPORT_CREATED, + metadata: [ + 'report_type' => 'permission_posture', + ], + subjectType: 'stored_report', + subjectId: 404, + occurredAt: CarbonImmutable::now()->subDays(2), + ); + + $lastDayStats = productTelemetryStats(Livewire::withQueryParams([ + 'window' => SystemConsoleWindow::LastDay, + ])->test(ProductTelemetryKpis::class)); + + expect($lastDayStats['Active workspaces'])->toBe([ + 'value' => '2', + 'description' => '4 events in Last 24 hours', + ]) + ->and($lastDayStats['Onboarding checkpoints']['value'])->toBe('1') + ->and($lastDayStats['Support diagnostics']['value'])->toBe('1') + ->and($lastDayStats['Operations started']['value'])->toBe('1') + ->and($lastDayStats['Stored reports']['value'])->toBe('0') + ->and($lastDayStats['Review packs requested']['value'])->toBe('1'); + + $lastWeekStats = productTelemetryStats(Livewire::withQueryParams([ + 'window' => SystemConsoleWindow::LastWeek, + ])->test(ProductTelemetryKpis::class)); + + expect($lastWeekStats['Active workspaces'])->toBe([ + 'value' => '2', + 'description' => '5 events in Last 7 days', + ]) + ->and($lastWeekStats['Stored reports']['value'])->toBe('1'); +}); + +it('renders an explicit zero state when the selected window has no telemetry rows', function (): void { + actingAsSystemConsoleUser(); + + $stats = productTelemetryStats(Livewire::withQueryParams([ + 'window' => SystemConsoleWindow::LastDay, + ])->test(ProductTelemetryKpis::class)); + + expect($stats['Active workspaces'])->toBe([ + 'value' => '0', + 'description' => 'No telemetry recorded in Last 24 hours.', + ]) + ->and($stats['Onboarding checkpoints']['value'])->toBe('0') + ->and($stats['Support diagnostics']['value'])->toBe('0') + ->and($stats['Operations started']['value'])->toBe('0') + ->and($stats['Stored reports']['value'])->toBe('0') + ->and($stats['Review packs requested']['value'])->toBe('0'); +}); + +it('uses the injected dashboard window when the livewire request query is absent', function (): void { + actingAsSystemConsoleUser(); + + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + seedProductTelemetryEvent( + tenant: $tenant, + user: $user, + eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED, + metadata: [ + 'checkpoint_key' => 'tenant_connected', + 'lifecycle_state' => 'ready', + 'completed_at' => CarbonImmutable::now()->subDays(3), + ], + subjectType: 'tenant_onboarding_session', + subjectId: 909, + occurredAt: CarbonImmutable::now()->subDays(3), + ); + + $stats = productTelemetryStats(Livewire::test(ProductTelemetryKpis::class, [ + 'window' => SystemConsoleWindow::LastWeek, + ])); + + expect($stats['Active workspaces'])->toBe([ + 'value' => '1', + 'description' => '1 events in Last 7 days', + ]) + ->and($stats['Onboarding checkpoints'])->toBe([ + 'value' => '1', + 'description' => 'Last 7 days', + ]); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryRetentionTest.php b/apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryRetentionTest.php new file mode 100644 index 00000000..c911168d --- /dev/null +++ b/apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryRetentionTest.php @@ -0,0 +1,118 @@ +set('tenantpilot.product_usage_event_retention_days', 90); + + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $oldEvent = ProductUsageEvent::factory()->forEvent( + ProductUsageEventCatalog::OPERATIONS_STARTED, + ['operation_type' => 'review_pack.generate'], + )->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'subject_type' => 'operation_run', + 'subject_id' => 'prune-old-run', + 'occurred_at' => now()->subDays(120), + ]); + + $recentEvent = ProductUsageEvent::factory()->forEvent( + ProductUsageEventCatalog::OPERATIONS_STARTED, + ['operation_type' => 'review_pack.generate'], + )->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'subject_type' => 'operation_run', + 'subject_id' => 'prune-recent-run', + 'occurred_at' => now()->subDays(10), + ]); + + $operationRun = OperationRun::factory()->forTenant($tenant)->create([ + 'created_at' => now()->subDays(120), + ]); + + $storedReport = StoredReport::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'created_at' => now()->subDays(120), + ]); + + $reviewPack = ReviewPack::factory()->ready()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'initiated_by_user_id' => (int) $user->getKey(), + 'created_at' => now()->subDays(120), + ]); + + $auditLog = AuditLog::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'action' => 'platform.test.audit', + 'status' => 'success', + 'metadata' => [], + 'recorded_at' => now()->subDays(120), + ]); + + $this->artisan('tenantpilot:product-usage:prune') + ->expectsOutputToContain('Deleted 1 product usage event(s) older than 90 days.') + ->assertSuccessful(); + + expect(ProductUsageEvent::query()->whereKey($oldEvent->getKey())->exists())->toBeFalse() + ->and(ProductUsageEvent::query()->whereKey($recentEvent->getKey())->exists())->toBeTrue() + ->and(OperationRun::query()->whereKey($operationRun->getKey())->exists())->toBeTrue() + ->and(StoredReport::query()->whereKey($storedReport->getKey())->exists())->toBeTrue() + ->and(ReviewPack::query()->whereKey($reviewPack->getKey())->exists())->toBeTrue() + ->and(AuditLog::query()->whereKey($auditLog->getKey())->exists())->toBeTrue(); +}); + +it('honors the explicit days override when pruning product usage events', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $oldEvent = ProductUsageEvent::factory()->forEvent( + ProductUsageEventCatalog::STORED_REPORT_CREATED, + ['report_type' => 'permission_posture'], + )->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'subject_type' => 'stored_report', + 'subject_id' => 'old-report', + 'occurred_at' => now()->subDays(35), + ]); + + $recentEvent = ProductUsageEvent::factory()->forEvent( + ProductUsageEventCatalog::STORED_REPORT_CREATED, + ['report_type' => 'permission_posture'], + )->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'subject_type' => 'stored_report', + 'subject_id' => 'recent-report', + 'occurred_at' => now()->subDays(10), + ]); + + $this->artisan('tenantpilot:product-usage:prune --days=30') + ->expectsOutputToContain('Deleted 1 product usage event(s) older than 30 days.') + ->assertSuccessful(); + + expect(ProductUsageEvent::query()->whereKey($oldEvent->getKey())->exists())->toBeFalse() + ->and(ProductUsageEvent::query()->whereKey($recentEvent->getKey())->exists())->toBeTrue(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/System/Spec114/ControlTowerDashboardTest.php b/apps/platform/tests/Feature/System/Spec114/ControlTowerDashboardTest.php index 9a2cd3c8..ecbeda0c 100644 --- a/apps/platform/tests/Feature/System/Spec114/ControlTowerDashboardTest.php +++ b/apps/platform/tests/Feature/System/Spec114/ControlTowerDashboardTest.php @@ -3,9 +3,14 @@ declare(strict_types=1); use App\Filament\System\Pages\Dashboard; +use App\Filament\System\Widgets\ControlTowerKpis; +use App\Filament\System\Widgets\ControlTowerRecentFailures; +use App\Filament\System\Widgets\ControlTowerTopOffenders; +use App\Filament\System\Widgets\ProductTelemetryKpis; use App\Models\PlatformUser; use App\Support\Auth\PlatformCapabilities; use App\Support\SystemConsole\SystemConsoleWindow; +use Filament\Widgets\WidgetConfiguration; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -50,3 +55,34 @@ ]) ->assertSet('window', SystemConsoleWindow::LastWeek); }); + +it('passes the selected window into all window-aware dashboard widgets', function () { + $platformUser = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::CONSOLE_VIEW, + ], + 'is_active' => true, + ]); + + $component = Livewire::actingAs($platformUser, 'platform') + ->withQueryParams(['window' => SystemConsoleWindow::LastWeek]) + ->test(Dashboard::class) + ->assertSet('window', SystemConsoleWindow::LastWeek); + + $widgets = $component->instance()->getWidgets(); + + expect($widgets)->toHaveCount(5) + ->and($widgets[1])->toBeInstanceOf(WidgetConfiguration::class) + ->and($widgets[2])->toBeInstanceOf(WidgetConfiguration::class) + ->and($widgets[3])->toBeInstanceOf(WidgetConfiguration::class) + ->and($widgets[4])->toBeInstanceOf(WidgetConfiguration::class) + ->and($widgets[1]->widget)->toBe(ControlTowerKpis::class) + ->and($widgets[2]->widget)->toBe(ProductTelemetryKpis::class) + ->and($widgets[3]->widget)->toBe(ControlTowerTopOffenders::class) + ->and($widgets[4]->widget)->toBe(ControlTowerRecentFailures::class) + ->and($widgets[1]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek]) + ->and($widgets[2]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek]) + ->and($widgets[3]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek]) + ->and($widgets[4]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek]); +}); diff --git a/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetryRecorderTest.php b/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetryRecorderTest.php new file mode 100644 index 00000000..a77ee8cf --- /dev/null +++ b/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetryRecorderTest.php @@ -0,0 +1,69 @@ +create(); + $tenant = Tenant::factory()->for($workspace)->create(); + $user = User::factory()->create(); + + $event = app(ProductTelemetryRecorder::class)->record( + eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED, + workspaceId: (int) $workspace->getKey(), + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + subjectType: 'tenant_onboarding_session', + subjectId: 42, + metadata: [ + 'checkpoint_key' => 'tenant_connected', + 'lifecycle_state' => 'completed', + ], + ); + + expect($event->event_name)->toBe(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED) + ->and($event->feature_area)->toBe('onboarding') + ->and($event->workspace_id)->toBe((int) $workspace->getKey()) + ->and($event->tenant_id)->toBe((int) $tenant->getKey()) + ->and($event->user_id)->toBe((int) $user->getKey()) + ->and($event->subject_type)->toBe('tenant_onboarding_session') + ->and($event->subject_id)->toBe('42') + ->and($event->metadata)->toBe([ + 'checkpoint_key' => 'tenant_connected', + 'lifecycle_state' => 'completed', + ]); + + $this->assertDatabaseHas('product_usage_events', [ + 'id' => (int) $event->getKey(), + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => (int) $user->getKey(), + 'event_name' => ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED, + 'feature_area' => 'onboarding', + 'subject_type' => 'tenant_onboarding_session', + 'subject_id' => '42', + ]); +}); + +it('rejects unknown event names before writing telemetry rows', function () { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->for($workspace)->create(); + $user = User::factory()->create(); + + expect(fn () => app(ProductTelemetryRecorder::class)->record( + eventName: 'product.unknown', + workspaceId: (int) $workspace->getKey(), + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + subjectType: 'tenant', + subjectId: 99, + ))->toThrow(InvalidArgumentException::class, 'Unknown product telemetry event'); +}); \ No newline at end of file diff --git a/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetrySafeMetadataTest.php b/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetrySafeMetadataTest.php new file mode 100644 index 00000000..18f13175 --- /dev/null +++ b/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetrySafeMetadataTest.php @@ -0,0 +1,83 @@ + ['checkpoint_key', 'operator@example.com'], + 'free text' => ['checkpoint_key', 'completed checkpoint'], + 'nested payload' => ['checkpoint_key', ['raw' => 'payload']], +]); + +it('normalizes timestamp metadata into ISO strings', function () { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->for($workspace)->create(); + $user = User::factory()->create(); + $completedAt = CarbonImmutable::parse('2026-04-26T19:40:38+00:00'); + + $event = app(ProductTelemetryRecorder::class)->record( + eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED, + workspaceId: (int) $workspace->getKey(), + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + subjectType: 'tenant_onboarding_session', + subjectId: 7, + metadata: [ + 'checkpoint_key' => 'tenant_connected', + 'lifecycle_state' => 'completed', + 'completed_at' => $completedAt, + ], + ); + + expect($event->metadata)->toBe([ + 'checkpoint_key' => 'tenant_connected', + 'lifecycle_state' => 'completed', + 'completed_at' => $completedAt->toIso8601String(), + ]); +}); + +it('rejects metadata keys that are not declared for the event', function () { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->for($workspace)->create(); + $user = User::factory()->create(); + + expect(fn () => app(ProductTelemetryRecorder::class)->record( + eventName: ProductUsageEventCatalog::OPERATIONS_STARTED, + workspaceId: (int) $workspace->getKey(), + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + subjectType: 'operation_run', + subjectId: 9, + metadata: [ + 'unknown_key' => 'tenant.review_pack.generate', + ], + ))->toThrow(InvalidArgumentException::class, 'Unsupported telemetry metadata keys'); +}); + +it('rejects unsafe metadata values', function (string $key, mixed $value) { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->for($workspace)->create(); + $user = User::factory()->create(); + + expect(fn () => app(ProductTelemetryRecorder::class)->record( + eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED, + workspaceId: (int) $workspace->getKey(), + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + subjectType: 'tenant_onboarding_session', + subjectId: 77, + metadata: [ + $key => $value, + 'lifecycle_state' => 'completed', + ], + ))->toThrow(InvalidArgumentException::class); +})->with('unsafeProductTelemetryMetadata'); \ No newline at end of file diff --git a/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetrySummaryQueryTest.php b/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetrySummaryQueryTest.php new file mode 100644 index 00000000..f34f80e7 --- /dev/null +++ b/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetrySummaryQueryTest.php @@ -0,0 +1,100 @@ +create(); + + $workspaceA = Workspace::factory()->create(); + $tenantA = Tenant::factory()->for($workspaceA)->create(); + + $workspaceB = Workspace::factory()->create(); + $tenantB = Tenant::factory()->for($workspaceB)->create(); + + ProductUsageEvent::factory() + ->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED, [ + 'checkpoint_key' => 'tenant_connected', + 'lifecycle_state' => 'completed', + ]) + ->create([ + 'workspace_id' => (int) $workspaceA->getKey(), + 'tenant_id' => (int) $tenantA->getKey(), + 'user_id' => (int) $user->getKey(), + 'subject_type' => 'tenant_onboarding_session', + 'subject_id' => '11', + 'occurred_at' => now()->subHours(2), + ]); + + ProductUsageEvent::factory() + ->forEvent(ProductUsageEventCatalog::OPERATIONS_STARTED, [ + 'operation_type' => 'tenant.review_pack.generate', + ]) + ->create([ + 'workspace_id' => (int) $workspaceB->getKey(), + 'tenant_id' => (int) $tenantB->getKey(), + 'user_id' => (int) $user->getKey(), + 'subject_type' => 'operation_run', + 'subject_id' => '22', + 'occurred_at' => now()->subHours(1), + ]); + + ProductUsageEvent::factory() + ->forEvent(ProductUsageEventCatalog::STORED_REPORT_CREATED, [ + 'report_type' => 'permission_posture', + ]) + ->create([ + 'workspace_id' => (int) $workspaceB->getKey(), + 'tenant_id' => (int) $tenantB->getKey(), + 'user_id' => (int) $user->getKey(), + 'subject_type' => 'stored_report', + 'subject_id' => '33', + 'occurred_at' => now()->subMinutes(30), + ]); + + ProductUsageEvent::factory() + ->forEvent(ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED, [ + 'source_surface' => 'tenant_dashboard', + ]) + ->create([ + 'workspace_id' => (int) $workspaceA->getKey(), + 'tenant_id' => (int) $tenantA->getKey(), + 'user_id' => (int) $user->getKey(), + 'subject_type' => 'tenant', + 'subject_id' => '44', + 'occurred_at' => now()->subDays(3), + ]); + + $summary = app(ProductTelemetrySummaryQuery::class)->summarize(now()->subDay()); + + expect($summary['active_workspaces'])->toBe(2) + ->and($summary['total_events'])->toBe(3) + ->and($summary['families'][ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED]) + ->toBe(['label' => 'Onboarding checkpoints', 'count' => 1]) + ->and($summary['families'][ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED]) + ->toBe(['label' => 'Support diagnostics', 'count' => 0]) + ->and($summary['families'][ProductUsageEventCatalog::OPERATIONS_STARTED]) + ->toBe(['label' => 'Operations started', 'count' => 1]) + ->and($summary['families'][ProductUsageEventCatalog::STORED_REPORT_CREATED]) + ->toBe(['label' => 'Stored reports', 'count' => 1]) + ->and($summary['families'][ProductUsageEventCatalog::REVIEW_PACK_REQUESTED]) + ->toBe(['label' => 'Review packs requested', 'count' => 0]); +}); + +it('returns a zero summary when the selected window has no events', function () { + $summary = app(ProductTelemetrySummaryQuery::class)->summarize(now()->subDay()); + + expect($summary['active_workspaces'])->toBe(0) + ->and($summary['total_events'])->toBe(0) + ->and(array_column($summary['families'], 'count')) + ->toBe([0, 0, 0, 0, 0]); +}); \ No newline at end of file diff --git a/apps/platform/tests/Unit/Support/ProductTelemetry/ProductUsageEventCatalogTest.php b/apps/platform/tests/Unit/Support/ProductTelemetry/ProductUsageEventCatalogTest.php new file mode 100644 index 00000000..80077473 --- /dev/null +++ b/apps/platform/tests/Unit/Support/ProductTelemetry/ProductUsageEventCatalogTest.php @@ -0,0 +1,31 @@ +names())->toBe([ + ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED, + ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED, + ProductUsageEventCatalog::OPERATIONS_STARTED, + ProductUsageEventCatalog::STORED_REPORT_CREATED, + ProductUsageEventCatalog::REVIEW_PACK_REQUESTED, + ])->and($catalog->visibleFamilies())->toBe([ + ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED => 'Onboarding checkpoints', + ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED => 'Support diagnostics', + ProductUsageEventCatalog::OPERATIONS_STARTED => 'Operations started', + ProductUsageEventCatalog::STORED_REPORT_CREATED => 'Stored reports', + ProductUsageEventCatalog::REVIEW_PACK_REQUESTED => 'Review packs requested', + ])->and($catalog->allowedMetadataKeys(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)) + ->toBe(['checkpoint_key', 'lifecycle_state', 'completed_at']); +}); + +it('rejects unknown product telemetry events', function () { + $catalog = new ProductUsageEventCatalog(); + + expect(fn () => $catalog->definition('product.unknown')) + ->toThrow(InvalidArgumentException::class, 'Unknown product telemetry event'); +}); \ No newline at end of file diff --git a/specs/243-product-usage-adoption-telemetry/checklists/requirements.md b/specs/243-product-usage-adoption-telemetry/checklists/requirements.md new file mode 100644 index 00000000..bda85b63 --- /dev/null +++ b/specs/243-product-usage-adoption-telemetry/checklists/requirements.md @@ -0,0 +1,42 @@ +# Specification Quality Checklist: Product Usage & Adoption Telemetry + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-26 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] Business value and operator outcomes stay explicit +- [x] Implementation anchors are intentional and bounded to support repo planning conventions +- [x] Runtime-governance sections are present for an implementation-ready spec package +- [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 remain outcome-focused even where implementation anchors are documented elsewhere in the package +- [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] Implementation detail is constrained to the repo's implementation-ready planning sections and does not weaken requirement clarity + +## Governance Readiness + +- [x] Runtime impact, validation lanes, and minimal proving commands are documented +- [x] Proportionality review is present for the new persisted telemetry truth +- [x] Provider-boundary handling and RBAC plane separation are explicit +- [x] Operator-facing surface changes include the required UI contract sections + +## Notes + +- This checklist completes the constitution-required runtime feature package alongside `spec.md`, `plan.md`, and `tasks.md`. +- The active slice stays bounded to five visible telemetry families, active-workspace participation, one dedicated ledger, and one aggregate system-dashboard widget. \ No newline at end of file diff --git a/specs/243-product-usage-adoption-telemetry/plan.md b/specs/243-product-usage-adoption-telemetry/plan.md new file mode 100644 index 00000000..9dc6e80b --- /dev/null +++ b/specs/243-product-usage-adoption-telemetry/plan.md @@ -0,0 +1,202 @@ +# Implementation Plan: Product Usage & Adoption Telemetry + +**Branch**: `243-product-usage-adoption-telemetry` | **Date**: 2026-04-26 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/243-product-usage-adoption-telemetry/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/243-product-usage-adoption-telemetry/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +- Add one tenant-owned telemetry ledger for a bounded set of user-initiated product milestones only: onboarding checkpoint completion, support diagnostics opened, tenant-bound operation started, stored report created, and review-pack generation requested. +- Reuse existing trustworthy source seams instead of inventing passive page tracking or scraping domain tables later: `OnboardingLifecycleService`, support-diagnostics actions, `OperationRunService`, `EntraAdminRolesReportService`, `PermissionPostureFindingGenerator`, and `ReviewPackService` become the only v1 write paths. +- Surface only one read-only adoption summary on the existing system dashboard through a native widget that follows the current `SystemConsoleWindow` filter semantics, renders five visible event families in v1, and includes active-workspace participation for the selected window. No raw event browser, no customer-facing analytics, and no AuditLog or OperationRun overloading are allowed. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OnboardingLifecycleService`, `OperationRunService`, `SupportDiagnosticBundleBuilder`, `ReviewPackService`, `EntraAdminRolesReportService`, `PermissionPostureFindingGenerator`, system dashboard widgets +**Storage**: PostgreSQL via one new tenant-owned `product_usage_events` table; source truth stays on existing onboarding, operation, report, and review-pack tables +**Testing**: Pest unit + feature tests only +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Sail-backed Laravel admin and system panels under `/admin` and `/system` +**Project Type**: web +**Performance Goals**: one cheap insert per eligible source milestone, no passive page-view chatter, and one indexed aggregate query for the system dashboard time window without scanning arbitrary logs +**Constraints**: tenant-bound rows only, no pre-tenant onboarding events, no initiator-null operation telemetry, no raw payloads or free text in metadata, no third-party analytics, no raw event browser, no customer-facing analytics, and no new panel or provider registration changes +**Scale/Scope**: 5 code-owned event names, 1 dashboard widget, 1 recorder, 1 summary query, 1 prune command, 1 config-backed 90-day retention rule, and focused source-seam instrumentation only + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament + shared stats widget +- **Shared-family relevance**: dashboard signals/cards +- **State layers in scope**: page, widget, 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 +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `App\Filament\System\Pages\Dashboard`, `App\Filament\System\Widgets\ControlTowerKpis`, `App\Services\Onboarding\OnboardingLifecycleService`, `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder`, `App\Services\OperationRunService`, `App\Services\EntraAdminRoles\EntraAdminRolesReportService`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Services\ReviewPackService`, and the support-diagnostics page actions on `TenantDashboard` and `TenantlessOperationRunViewer` +- **Shared abstractions reused**: existing system dashboard widget conventions, existing source-owned service/action seams, and current workspace/tenant context resolution before writes +- **New abstraction introduced? why?**: one bounded `ProductTelemetryRecorder`, one code-owned event catalog, and one summary query are justified because telemetry semantics do not belong on the existing audit, operation, or user-preference models +- **Why the existing abstraction was sufficient or insufficient**: existing source seams know when a trustworthy milestone happened, but there is no shared telemetry contract or aggregate read path today +- **Bounded deviation / spread control**: no page-local counters, no direct writes from Blade or Livewire render hooks, and no domain-table-specific telemetry sidecar fields + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no +- **Central contract reused**: N/A +- **Delegated UX behaviors**: N/A +- **Surface-owned behavior kept local**: N/A +- **Queued DB-notification policy**: N/A +- **Terminal notification path**: N/A +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes +- **Provider-owned seams**: provider-backed operation types, report generation sources, support-diagnostic provider context +- **Platform-core seams**: telemetry event names, feature-area labels, safe metadata schema, system dashboard widget labels +- **Neutral platform terms / contracts preserved**: product telemetry, usage event, feature area, subject reference, active workspaces, recent signals +- **Retained provider-specific semantics and why**: stable canonical operation and report type identifiers may appear in safe metadata because they are already product-owned identifiers used across the repo +- **Bounded extraction or follow-up path**: no multi-provider telemetry abstraction beyond the bounded event catalog; later customer-health work reuses this shape rather than adding a parallel one + +## Constitution Check + +*GATE: Must pass before implementation begins. Re-check after design changes.* + +- Inventory-first / snapshots-second: PASS - telemetry observes product usage only and does not become an external source of truth for tenant configuration, inventory, or backup state +- Read/write separation: PASS - telemetry writes are bounded product-observability writes triggered after existing source actions succeed; no tenant-changing behavior is added +- Graph contract path: PASS - the feature adds no new Graph calls +- RBAC-UX plane separation: PASS - writes originate in existing admin-plane flows after authorization; reads remain system-plane only via the existing dashboard gate +- Workspace isolation / tenant isolation: PASS - telemetry rows are tenant-owned with `workspace_id` and `tenant_id` required; no cross-tenant raw event viewer is introduced +- Run observability / Ops-UX: PASS - `OperationRun` remains execution truth only; telemetry observes a successful tenant-bound user start without altering run UX or lifecycle +- Shared pattern reuse / `XCUT-001`: PASS - widget reuse and source-seam reuse are explicit; no page-local or model-local side ledgers are planned +- Provider boundary / `PROV-001`: PASS - telemetry stores platform-neutral event names and only stable canonical type identifiers, not provider payload or provider transport truth +- Proportionality / `PROP-001` and `ABSTR-001`: PASS - the new structure is justified by a concrete operator need and kept to one bounded ledger, one recorder, one summary query, and one widget +- Persisted truth / `PERSIST-001`: PASS - telemetry rows represent independent product-observability truth with their own retention lifecycle and later reuse by Customer Health Score +- Behavioral state / `STATE-001`: PASS - the event catalog changes later operator visibility and product-health workflows; it is not presentation-only decoration +- Filament-native UI / `UI-FIL-001`: PASS - visibility stays on a native system widget only +- Global search rule: N/A - no new global-searchable resource is introduced +- Panel/provider registration: PASS - no panel or provider registration changes are planned; Livewire remains v4-compatible and provider registration stays in `bootstrap/providers.php` +- Test governance / `TEST-GOV-001`: PASS - proof stays in focused unit + feature coverage only + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for event-catalog legality, safe metadata, and summary-query behavior; Feature for source capture from real service/action seams plus dashboard access and visibility +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: the feature is server-driven and data-focused; unit tests prove the bounded contract, while feature tests prove the real write and read seams without browser duplication +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductTelemetry/ProductUsageEventCatalogTest.php tests/Unit/Support/ProductTelemetry/ProductTelemetryRecorderTest.php tests/Unit/Support/ProductTelemetry/ProductTelemetrySafeMetadataTest.php tests/Unit/Support/ProductTelemetry/ProductTelemetrySummaryQueryTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ProductTelemetryOnboardingCaptureTest.php tests/Feature/SupportDiagnostics/ProductTelemetrySupportDiagnosticsCaptureTest.php tests/Feature/Operations/ProductTelemetryOperationStartCaptureTest.php tests/Feature/Reports/ProductTelemetryReportCaptureTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ProductTelemetry/ProductTelemetryDashboardWidgetTest.php tests/Feature/System/ProductTelemetry/ProductTelemetryAuthorizationTest.php tests/Feature/System/ProductTelemetry/ProductTelemetryRetentionTest.php tests/Feature/System/ProductTelemetry/NoAdHocTelemetryBypassTest.php` +- **Fixture / helper / factory / seed / context cost risks**: reuse existing workspace, tenant, user, onboarding session, operation-run, stored-report, and review-pack fixtures; keep any telemetry helper local to this family only +- **Expensive defaults or shared helper growth introduced?**: no +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: standard-native-filament relief is sufficient for the system widget; no browser harness is required +- **Closing validation and reviewer handoff**: reviewers should verify tenant-bound rows only, safe metadata only, no AuditLog or OperationRun overload, no passive page-view events, no initiator-null capture, and no raw event browser +- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep +- **Review-stop questions**: did the implementation add passive page views, a raw event list, or a second telemetry store; did any metadata accept free text or raw payloads; did any read surface leave the system plane? +- **Escalation path**: `reject-or-split` if implementation widens into broad analytics or customer-facing dashboards; `document-in-feature` for small source-seam additions that stay bounded to the first-slice catalog +- **Active feature PR close-out entry**: Guardrail + +## Project Structure + +### Documentation (this feature) + +```text +specs/243-product-usage-adoption-telemetry/ +├── checklists/ +│ └── requirements.md +├── spec.md +├── plan.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/Pages/Operations/TenantlessOperationRunViewer.php +│ ├── Filament/Pages/TenantDashboard.php +│ ├── Filament/System/Pages/Dashboard.php +│ ├── Filament/System/Widgets/ +│ │ └── ProductTelemetryKpis.php +│ ├── Models/ +│ │ └── ProductUsageEvent.php +│ ├── Support/ProductTelemetry/ +│ │ ├── ProductTelemetryRecorder.php +│ │ ├── ProductTelemetrySummaryQuery.php +│ │ └── ProductUsageEventCatalog.php +│ ├── Services/Onboarding/OnboardingLifecycleService.php +│ ├── Services/EntraAdminRoles/EntraAdminRolesReportService.php +│ ├── Services/PermissionPosture/PermissionPostureFindingGenerator.php +│ ├── Services/ReviewPackService.php +│ ├── Services/OperationRunService.php +│ ├── Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php +│ └── Console/Commands/ +│ └── PruneProductUsageEventsCommand.php +├── config/ +│ └── tenantpilot.php +├── database/ +│ ├── factories/ +│ │ └── ProductUsageEventFactory.php +│ └── migrations/ +│ └── *_create_product_usage_events_table.php +├── routes/ +│ └── console.php +└── tests/ + ├── Unit/Support/ProductTelemetry/ + │ ├── ProductUsageEventCatalogTest.php + │ ├── ProductTelemetryRecorderTest.php + │ ├── ProductTelemetrySafeMetadataTest.php + │ └── ProductTelemetrySummaryQueryTest.php + └── Feature/ + ├── Onboarding/ProductTelemetryOnboardingCaptureTest.php + ├── Operations/ProductTelemetryOperationStartCaptureTest.php + ├── Reports/ProductTelemetryReportCaptureTest.php + ├── SupportDiagnostics/ProductTelemetrySupportDiagnosticsCaptureTest.php + └── System/ProductTelemetry/ + ├── ProductTelemetryAuthorizationTest.php + ├── ProductTelemetryDashboardWidgetTest.php + ├── ProductTelemetryRetentionTest.php + └── NoAdHocTelemetryBypassTest.php +``` + +**Structure Decision**: Single Laravel web application. The feature adds one bounded telemetry support namespace and one system widget while reusing existing domain services and support-diagnostics page actions as source seams. + +## Complexity Tracking + +No constitution violations are required. The only new persisted truth and abstraction are the explicitly justified tenant-owned telemetry ledger plus its bounded recorder and summary query. + +## Proportionality Review + +- **Current operator problem**: product adoption and usage still require anecdotal inference or log inspection +- **Existing structure is insufficient because**: audit, operation, report, review-pack, and tenant-preference models each describe different truths and cannot safely stand in for adoption telemetry +- **Narrowest correct implementation**: one tenant-owned event table, one bounded event catalog, one recorder, one summary query, and one aggregate system widget +- **Ownership cost created**: migration, model, recorder, query, prune command, widget, config key, scheduler entry, and focused tests +- **Alternative intentionally rejected**: AuditLog piggyback, OperationRun-context piggyback, `UserTenantPreference` counters, passive page-view tracking, third-party analytics +- **Release truth**: current-release truth + +## Rollout & Risk Controls + +- Start with five code-owned event names only. Adding more events requires revisiting the spec scope, not silent catalog growth. +- Keep the first slice tenant-bound and user-initiated only. Pre-tenant onboarding and system-initiated signals are explicit non-goals. +- Keep the read surface aggregate-only on `/system`. A raw event list or customer-facing reporting requires a later spec. +- Use a config-backed 90-day retention window via `tenantpilot.product_usage_event_retention_days` and schedule `tenantpilot:product-usage:prune` daily in `apps/platform/routes/console.php` so telemetry does not become an unbounded side history. + +## Implementation Outline + +- Add the `product_usage_events` table, model, factory, bounded catalog, recorder, summary query, config-backed retention rule, and prune command. +- Instrument the five declared source seams only: onboarding checkpoint completion, support diagnostics opened, tenant-bound user-started operation, stored-report creation, and review-pack generation request. +- Add a native system dashboard widget that reuses the existing `SystemConsoleWindow` selection and shows aggregate counts only. +- Add unit and feature tests that prove safe metadata, tenant-bound scope, source capture, system access, and retention. + +## Constitution Check (Post-Design) + +Re-check result: PASS. The plan stays bounded to one tenant-owned observability ledger, reuses existing source seams and native system widgets, keeps provider specifics out of the platform-core contract, leaves `OperationRun` UX unchanged, fixes retention to one explicit config-backed 90-day rule with a daily scheduler anchor in `apps/platform/routes/console.php`, and limits proof to unit + feature coverage. \ No newline at end of file diff --git a/specs/243-product-usage-adoption-telemetry/spec.md b/specs/243-product-usage-adoption-telemetry/spec.md new file mode 100644 index 00000000..c5a02450 --- /dev/null +++ b/specs/243-product-usage-adoption-telemetry/spec.md @@ -0,0 +1,256 @@ +# Feature Specification: Product Usage & Adoption Telemetry + +**Feature Branch**: `243-product-usage-adoption-telemetry` +**Created**: 2026-04-26 +**Status**: Ready for implementation +**Input**: User description: "Promote the roadmap-fit candidate Product Usage & Adoption Telemetry as a narrow, implementation-ready slice that introduces a privacy-aware internal product telemetry contract for high-signal adoption events across onboarding readiness, support diagnostics, tenant-bound operations, stored reports, and review-pack generation. The slice should reuse existing workspace and tenant context resolution plus existing source records, keep telemetry truth separate from AuditLog and OperationRun truth, and surface only one basic operator-facing aggregate on the existing system dashboard. Out of scope: third-party analytics, passive page-view tracking, session recording, customer-facing analytics dashboards, marketing attribution, free-text metadata, or a broad BI platform." + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: TenantPilot still lacks a product-owned signal for whether high-value product capabilities are actually being used after onboarding. Founder and system operators must infer adoption from support conversations, raw database inspection, or unrelated logs. +- **Today's failure**: The product cannot answer which tenant-bound workflows are being adopted, where usage is stalling, or whether support, stored-report, and review-pack features are being used at all without mixing advisory telemetry into AuditLog or reading domain tables manually. +- **User-visible improvement**: A platform operator can open the existing system dashboard and see one privacy-aware adoption summary for a bounded set of high-signal product milestones, while later health-score and lifecycle features can consume the same telemetry truth instead of re-inventing their own counters. +- **Smallest enterprise-capable version**: Introduce one tenant-owned telemetry event ledger plus one code-owned event catalog for a bounded first slice of user-initiated milestones only: onboarding checkpoint completed, support diagnostics opened, tenant-bound operation started, stored report created, and review-pack generation requested. Surface only an aggregate KPI-style summary on the existing `/system` dashboard. +- **Explicit non-goals**: No third-party analytics vendor, no passive page-view or session replay tracking, no customer-facing analytics, no marketing attribution, no full event browser, no broad BI dashboard, no telemetry for pre-tenant onboarding drafts, no raw payload capture, no free-text metadata, and no repurposing of `AuditLog`, `OperationRun`, or `UserTenantPreference` as the telemetry store. +- **Permanent complexity imported**: One new tenant-owned table and model, one bounded telemetry catalog, one recorder and summary query path, one system dashboard widget, one retention/pruning rule, and focused unit plus feature coverage. +- **Why now**: Self-Service Tenant Onboarding & Connection Readiness is already Spec 240, Support Diagnostic Pack is already Spec 241, and Operational Controls is already Spec 242. Customer Health Score, lifecycle communication, and later AI-governed product operations all depend on reliable adoption signals rather than anecdotes. +- **Why not local**: Local counters on one page or model would either mix telemetry into unrelated source-of-truth tables or leave future adoption consumers to scrape several different domain records with inconsistent semantics. +- **Approval class**: Core Enterprise +- **Red flags triggered**: New persistence, new meta-infrastructure, foundation-sounding theme. Defense: the slice is explicitly limited to five code-owned event names, one aggregate dashboard widget, tenant-owned rows only, and no customer-facing analytics or generic instrumentation platform. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: platform, workspace, tenant +- **Primary Routes**: + - `/system` existing system dashboard for aggregate telemetry visibility + - `/admin/onboarding/{onboardingDraft}` and the linked `/admin/onboarding` resume flow when a tenant is already linked and an onboarding checkpoint transition occurs + - Tenant-bound support diagnostics entry points on the tenant dashboard and canonical operation detail viewer + - Existing tenant-bound operation start, stored-report creation, and review-pack generation seams +- **Data Ownership**: `product_usage_events` is tenant-owned telemetry truth. Every row must include `workspace_id` and `tenant_id` as non-null scope columns. Source truth remains on `TenantOnboardingSession`, `OperationRun`, `StoredReport`, `ReviewPack`, and the existing support-diagnostics actions. The system dashboard reads aggregate summaries over those tenant-owned rows but does not become the source of truth. +- **RBAC**: Telemetry writes occur only after the originating admin-plane action or service has already resolved workspace membership, tenant entitlement, and any required capability. No tenant/admin plane telemetry viewer is introduced. Aggregate read access remains system-plane only through the existing system dashboard access rules; tenant/admin users cannot query raw telemetry rows in this slice. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: N/A - the first visibility surface is the existing `/system` dashboard, not an admin-plane tenant-context collection view. +- **Explicit entitlement checks preventing cross-tenant leakage**: Tenant telemetry rows stay tenant-owned, and the system dashboard surfaces only aggregate counts and bounded labels in v1. No raw event list or cross-tenant record drilldown is introduced. + +## 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 +- **Interaction class(es)**: dashboard signals/cards, onboarding milestone capture, support action capture, operation-start capture, report-generation capture +- **Systems touched**: system dashboard widget composition, onboarding lifecycle transitions, support diagnostics actions, tenant-bound operation start services, stored-report generation services, review-pack generation service, and existing tenant-context resolution at the app boundary +- **Existing pattern(s) to extend**: existing system dashboard widget pattern, source-owned service/action seams, and current workspace/tenant context derivation before writes +- **Shared contract / presenter / builder / renderer to reuse**: `App\Filament\System\Pages\Dashboard`, `App\Filament\System\Widgets\ControlTowerKpis`, `App\Services\Onboarding\OnboardingLifecycleService`, `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder`, `App\Services\OperationRunService`, `App\Services\EntraAdminRoles\EntraAdminRolesReportService`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, and `App\Services\ReviewPackService` +- **Why the existing shared path is sufficient or insufficient**: The repo already has trustworthy source seams for when a high-signal milestone happens, and it already has a native system dashboard widget surface. What it does not have is one bounded telemetry contract that records those milestones without overloading AuditLog, OperationRun, or user preference state. +- **Allowed deviation and why**: One new `ProductTelemetryRecorder` plus a code-owned event catalog are allowed because telemetry semantics do not belong on existing audit or operation models. No page-local counters or domain-specific side ledgers are allowed. +- **Consistency impact**: Event names, feature-area labels, safe metadata keys, dashboard labels, and time-window semantics must stay aligned across all emission seams and the aggregate widget. +- **Review focus**: Reviewers must verify that no telemetry write piggybacks on `AuditLog`, no raw provider payload or free text is stored, no passive page-view spam is introduced, and no source seam writes telemetry before entitlement or source success is established. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: no +- **Shared OperationRun UX contract/layer reused**: N/A - the slice may observe already-started tenant-bound runs as telemetry source events, but it does not change start, completion, link, or notification behavior. +- **Delegated start/completion UX behaviors**: N/A +- **Local surface-owned behavior that remains**: N/A +- **Queued DB-notification policy**: N/A +- **Terminal notification path**: N/A +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: yes +- **Boundary classification**: mixed +- **Seams affected**: operation-type labels, report-type labels, review-pack generation source semantics, safe metadata keys, aggregate widget labels +- **Neutral platform terms preserved or introduced**: product telemetry, usage event, feature area, subject reference, occurred at, workspace, tenant, active workspace count, recent signals +- **Provider-specific semantics retained and why**: Existing canonical operation types and report types may appear in metadata when they already represent stable product-owned identifiers. Raw Graph endpoints, payloads, provider error bodies, or provider-only vocabulary stay out of telemetry rows. +- **Why this does not deepen provider coupling accidentally**: The telemetry contract records product event names and canonical source identifiers, not provider transport or payload truth. It treats provider-backed events as source references only. +- **Follow-up path**: Customer Health Score and lifecycle communication can reuse this contract later, but they remain separate specs. + +## 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 | +|---|---|---|---|---|---|---| +| System dashboard telemetry widget | yes | Native Filament + shared stats widget | dashboard signals/cards | page, widget, window query | no | Read-only KPI addition on the existing `/system` dashboard | +| Source emission seams | no | N/A | none | none | no | `N/A - server-side capture only` | + +## 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 | +|---|---|---|---|---|---|---|---| +| System dashboard telemetry widget | Secondary Context Surface | A platform operator reviews recent product adoption and decides whether onboarding, support, operations, stored-report, or review-pack usage needs follow-up elsewhere | Five visible event-family counts, active workspace count, and selected time window | Raw event rows are intentionally out of scope in v1; follow-up happens on existing onboarding, operations, support, stored-report, and review-pack surfaces | Not primary because this slice does not create a new queue or workflow hub; it adds context for product-operability decisions | Fits the founder/system-operator control-tower loop | Replaces manual log and database inspection with one bounded product signal summary | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| System dashboard telemetry widget | Dashboard / Overview / KPI widget | System observability summary | Continue monitoring or open the existing product surface that needs follow-up | In-page stats widget on the system dashboard | forbidden | Existing dashboard actions remain outside the widget | none | `/system` | `/system` | Existing dashboard time window plus bounded telemetry event families | Product telemetry / Product telemetry summary | Recent high-signal usage counts and active-workspace participation | 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| System dashboard telemetry widget | Platform operator / founder | Decide whether onboarding, support, operations, stored reports, and review packs show real recent adoption | Dashboard widget | Which tenant-bound product capabilities are being used recently, and across how many workspaces? | Five visible event-family counts, active workspace count, and selected time window | Raw event rows, subject-specific drilldowns, and customer-facing reporting remain out of scope | adoption volume, signal freshness | none | existing dashboard window selection only | none | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| System dashboard telemetry widget | `App\Filament\System\Pages\Dashboard` + `App\Filament\System\Widgets\ProductTelemetryKpis` | Reuse the existing dashboard `Time window` action; no new header action for telemetry | n/a | none | none | none added; widget renders its own zero-state summary | n/a | n/a | no | Read-only stats widget only; no new action group, no drilldown list, no destructive behavior | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: yes +- **New persisted entity/table/artifact?**: yes +- **New abstraction?**: yes +- **New enum/state/reason family?**: yes, one bounded telemetry event catalog and feature-area classification +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Product operability decisions still depend on anecdotes and log inspection because the platform has no bounded telemetry truth for adoption milestones. +- **Existing structure is insufficient because**: `AuditLog` records compliance and mutation truth, `OperationRun` records execution truth, `StoredReport` and `ReviewPack` record artifact truth, and `UserTenantPreference` records tenant affinity. None of them can safely answer cross-feature adoption without semantic drift. +- **Narrowest correct implementation**: Add one tenant-owned event table for a bounded first-slice catalog, record events only from user-initiated high-signal source seams, and show only an aggregate KPI summary on the existing system dashboard. +- **Ownership cost**: One new model and migration, one bounded support namespace, one widget, one pruning rule, and a focused set of unit plus feature tests. +- **Alternative intentionally rejected**: Piggyback on `AuditLog`, overloading `OperationRun` context, extending `UserTenantPreference` with counters, raw page-view tracking, or integrating a third-party analytics platform. +- **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**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: Unit tests can prove event-catalog legality, metadata normalization, tenant-scope requirements, and aggregate summary queries. Feature tests can prove source capture from real service and action seams plus system-dashboard visibility and authorization without browser automation. +- **New or expanded test families**: One focused `ProductTelemetry` unit family plus targeted feature coverage for onboarding capture, support diagnostics capture, operation-start capture, report and review-pack capture, dashboard visibility, and authorization or isolation rules. +- **Fixture / helper cost impact**: Moderate. Reuse existing workspaces, tenants, users, onboarding sessions, operation runs, stored reports, and review packs. Add only one feature-local telemetry factory and a small set of feature-local assertions for safe metadata. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: standard-native-filament +- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient for the system dashboard widget. Source seams require server-side feature tests, not browser flow tests. +- **Reviewer handoff**: Reviewers must verify that no telemetry write piggybacks on `AuditLog`, no raw provider payload or free text is stored, no passive page-view spam is introduced, no source seam writes telemetry before entitlement or source success is established, and no tenant/admin telemetry viewer is introduced. +- **Budget / baseline / trend impact**: Low-to-moderate increase in narrow unit plus feature coverage only. +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductTelemetry/ProductUsageEventCatalogTest.php tests/Unit/Support/ProductTelemetry/ProductTelemetryRecorderTest.php tests/Unit/Support/ProductTelemetry/ProductTelemetrySummaryQueryTest.php tests/Unit/Support/ProductTelemetry/ProductTelemetrySafeMetadataTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ProductTelemetryOnboardingCaptureTest.php tests/Feature/SupportDiagnostics/ProductTelemetrySupportDiagnosticsCaptureTest.php tests/Feature/Operations/ProductTelemetryOperationStartCaptureTest.php tests/Feature/Reports/ProductTelemetryReportCaptureTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ProductTelemetry/ProductTelemetryDashboardWidgetTest.php tests/Feature/System/ProductTelemetry/ProductTelemetryAuthorizationTest.php tests/Feature/System/ProductTelemetry/ProductTelemetryRetentionTest.php tests/Feature/System/ProductTelemetry/NoAdHocTelemetryBypassTest.php` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Record high-signal tenant usage centrally (Priority: P1) + +As a platform owner, I need one bounded telemetry contract that records real tenant-bound product milestones from existing source seams so later health and lifecycle features do not have to guess adoption from unrelated models. + +**Why this priority**: Without a trustworthy write path, any dashboard or health score would be based on scraped or inconsistent source data. + +**Independent Test**: Trigger each supported source milestone once with an entitled tenant admin user and confirm exactly one tenant-bound telemetry row is written with safe metadata only. + +**Acceptance Scenarios**: + +1. **Given** an entitled tenant admin completes an onboarding checkpoint after a tenant is already linked, **When** the checkpoint transition succeeds, **Then** the system writes one telemetry event referencing the tenant, workspace, user, and onboarding subject without recording free text or raw provider data. +2. **Given** an entitled user opens support diagnostics, starts a tenant-bound operation, creates a stored report, or requests review-pack generation, **When** the source action succeeds, **Then** the system writes exactly one bounded telemetry event for that source milestone. +3. **Given** the source action fails or the tenant context is not yet established, **When** the action exits, **Then** no telemetry event is written in v1. + +--- + +### User Story 2 - See bounded adoption signals on the system dashboard (Priority: P1) + +As a platform operator, I want one read-only dashboard summary for recent product adoption so I can see whether onboarding, support, operations, stored-report, and review-pack flows are actually being used. + +**Why this priority**: The telemetry foundation is not useful unless it is queryable without database inspection. + +**Independent Test**: Seed bounded telemetry events across multiple workspaces and confirm the existing system dashboard shows aggregate counts for the selected time window without exposing raw rows. + +**Acceptance Scenarios**: + +1. **Given** recent telemetry rows exist for multiple tenants and workspaces, **When** an authorized platform user opens `/system`, **Then** the dashboard shows aggregate counts by event family and the number of active workspaces for the selected time window. +2. **Given** no recent telemetry exists in the selected window, **When** the dashboard renders, **Then** the telemetry widget shows an explicit zero-state summary instead of failing or implying missing data is healthy. + +--- + +### User Story 3 - Keep telemetry private, tenant-bound, and cheap (Priority: P2) + +As the product owner, I need telemetry to remain privacy-aware and scoped so the feature does not become a second audit log, a second operation store, or an uncontrolled analytics system. + +**Why this priority**: Telemetry that leaks tenant detail or grows through noisy page events would create trust and maintenance debt immediately. + +**Independent Test**: Generate supported telemetry events, inspect the stored rows and retention path, and verify that only tenant-bound safe metadata is stored and that old rows can be pruned without touching source truth. + +**Acceptance Scenarios**: + +1. **Given** a supported source event carries identifiers, **When** telemetry is written, **Then** only bounded IDs and enumerated metadata are stored and no raw provider payload, no email address, and no arbitrary notes are persisted. +2. **Given** a user lacks system dashboard access, **When** they attempt to read aggregate telemetry, **Then** the system denies access through the existing system-plane access rules and does not expose raw rows anywhere else. +3. **Given** telemetry rows are older than the configured 90-day retention window, **When** the daily `tenantpilot:product-usage:prune` path runs, **Then** those rows are deleted without affecting `AuditLog`, `OperationRun`, `StoredReport`, or `ReviewPack` truth. + +### Edge Cases + +- An onboarding draft can exist before a tenant is linked; v1 must not emit telemetry for pre-tenant onboarding activity. +- A tenant-bound `OperationRun` can be system-initiated or scheduled; v1 must not treat system-started or initiator-null runs as user-adoption signals. +- A support-diagnostics action can re-render or refresh within Livewire; v1 must emit telemetry only on the explicit successful open action, not on page render. +- A stored report or review-pack request can reuse existing source truth; v1 should emit only when a real create or request seam succeeds, not when a page merely displays an existing record. +- The selected system dashboard window can contain zero rows; the widget must render an explicit empty summary without falling back to logs or raw queries. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature adds no new Microsoft Graph call path and no new tenant-changing action. It introduces a new tenant-owned observability truth for product adoption only. It must not change existing operation, report, or review-pack execution semantics. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces one new persisted truth because current-release product operability now needs a bounded adoption ledger. The design stays narrow: a tenant-owned event table, a bounded event catalog, a recorder, a summary query, and a dashboard widget. No generic analytics framework or page-view tracker is allowed. + +**Constitution alignment (XCUT-001):** This slice is cross-cutting across dashboard signals and source-owned milestone seams. It must reuse the existing system dashboard widget pattern and the existing source services or actions instead of adding page-local counters. + +**Constitution alignment (PROV-001):** Telemetry fields stay platform-neutral. Existing canonical operation types and report types may appear only as stable source identifiers, not as provider payload or Graph truth. + +**Constitution alignment (TEST-GOV-001):** Proof stays in narrow unit and feature lanes. No browser or heavy-governance family is justified. + +**Constitution alignment (RBAC-UX):** Writes occur only after the source action has already passed its existing tenant and workspace authorization. Reads remain system-plane only and rely on the existing system dashboard access rules. No tenant/admin raw telemetry viewer exists in v1. + +**Constitution alignment (OPS-UX):** Existing `OperationRun` lifecycle, status, outcome, and notification rules remain unchanged. Telemetry may observe a successful user-initiated tenant-bound start but must not alter run creation or feedback. + +**Constitution alignment (BADGE-001):** The system dashboard widget uses native stat presentation only; no new status-badge family is introduced. + +**Constitution alignment (UI-FIL-001):** The only operator-facing addition is one native Filament system widget on the existing dashboard. + +**Constitution alignment (UI-NAMING-001):** Operator-facing labels remain simple and platform-neutral, such as `Product telemetry`, `Onboarding checkpoints`, `Support diagnostics`, `Operations started`, `Stored reports`, and `Review packs requested`. + +**Constitution alignment (DECIDE-001):** The widget is a secondary context surface only. It must not become a new queue or broad analytics workbench in this slice. + +**Constitution alignment (OPSURF-001):** Default-visible content stays operator-first: recent counts and active-workspace participation. Raw event rows, subject lists, and payload detail remain out of scope. + +### Functional Requirements + +- **FR-243-001**: The system MUST define one bounded, code-owned telemetry event catalog for the first slice, limited to user-initiated tenant-bound milestones such as onboarding checkpoint completed, support diagnostics opened, tenant operation started, stored report created, and review-pack generation requested. +- **FR-243-002**: The system MUST persist telemetry in a dedicated tenant-owned table with `workspace_id` and `tenant_id` as non-null scope columns and MUST NOT store telemetry in `AuditLog`, `OperationRun`, `StoredReport`, `ReviewPack`, or `UserTenantPreference`. +- **FR-243-003**: Telemetry rows MUST record a stable event name, feature area, actor reference, subject type, subject ID, occurred-at timestamp, and safe metadata only. +- **FR-243-004**: Safe metadata MUST be limited to bounded IDs, enums, booleans, timestamps, and canonical type strings. Free-text notes, email addresses, raw provider payloads, tokens, and arbitrary JSON blobs are forbidden. +- **FR-243-005**: Telemetry capture MUST run only after the originating source action or service has succeeded and only when a real tenant context exists. +- **FR-243-006**: Pre-tenant onboarding activity and initiator-null or scheduled system actions MUST NOT be recorded as product-adoption telemetry in v1. +- **FR-243-007**: The implementation MUST instrument the existing source seams rather than page renders: onboarding checkpoint transition, support-diagnostics open action, tenant-bound user-initiated operation start, stored-report creation, and review-pack generation request. +- **FR-243-008**: The system MUST provide one aggregate, read-only telemetry summary on the existing system dashboard that reports five visible event families in v1 (`Onboarding checkpoints`, `Support diagnostics`, `Operations started`, `Stored reports`, and `Review packs requested`) plus active-workspace participation for the selected time window. +- **FR-243-009**: Only platform users who already satisfy the existing system dashboard access rules may view telemetry aggregates in v1. No tenant/admin-plane telemetry viewer or raw event list is allowed. +- **FR-243-010**: When the selected time window contains no telemetry rows, the dashboard summary MUST render an explicit zero-state rather than failing or inferring adoption from unrelated source tables. +- **FR-243-011**: Telemetry retention MUST default to 90 days through `tenantpilot.product_usage_event_retention_days`, and rows older than that window MUST be removed by the daily `tenantpilot:product-usage:prune` schedule entry in `apps/platform/routes/console.php` without touching source-of-truth records. +- **FR-243-012**: The system MUST keep telemetry query cost bounded through a summary query path and appropriate table indexes; the dashboard must not scan arbitrary application logs. + +## Success Criteria + +- The existing `/system` dashboard shows exactly five visible event families (`Onboarding checkpoints`, `Support diagnostics`, `Operations started`, `Stored reports`, and `Review packs requested`) plus active-workspace participation for the selected time window and renders an explicit zero state when the window has no telemetry rows. +- 100% of v1 telemetry rows store non-null `workspace_id` and `tenant_id` and originate only from the declared user-initiated source seams. +- 0 telemetry rows in v1 store raw provider payloads, email addresses, or free-text notes. +- The daily `tenantpilot:product-usage:prune` path removes telemetry rows older than the configured 90-day retention window without mutating `AuditLog`, `OperationRun`, `StoredReport`, or `ReviewPack` records. + +## Assumptions + +- The first implementation slice records only tenant-bound, user-initiated admin-plane usage signals. +- System dashboard access rules remain the only read gate for aggregate telemetry in v1. +- Existing operation, report, and review-pack services expose reliable success seams that can emit telemetry without inventing new workflow steps. + +## Risks + +- If emission is attached to noisy render paths instead of explicit service or action seams, the ledger will become unusably chatty. +- If event catalog growth is not kept bounded, the feature could drift into a generic analytics platform. +- Pre-tenant onboarding drop-off remains out of scope until a later slice can justify a separate workspace-owned telemetry truth. \ No newline at end of file diff --git a/specs/243-product-usage-adoption-telemetry/tasks.md b/specs/243-product-usage-adoption-telemetry/tasks.md new file mode 100644 index 00000000..09173057 --- /dev/null +++ b/specs/243-product-usage-adoption-telemetry/tasks.md @@ -0,0 +1,186 @@ +--- + +description: "Task list for Product Usage & Adoption Telemetry" + +--- + +# Tasks: Product Usage & Adoption Telemetry + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/243-product-usage-adoption-telemetry/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/243-product-usage-adoption-telemetry/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/243-product-usage-adoption-telemetry/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/243-product-usage-adoption-telemetry/checklists/requirements.md` (required) + +**Tests**: REQUIRED (Pest) for all runtime behavior changes in this slice. Keep proof in Unit + Feature lanes only. +**Operations**: This slice must not alter existing `OperationRun` UX or lifecycle. It may observe successful user-initiated tenant-bound operation starts as telemetry sources only after the current start contract succeeds. +**RBAC**: Telemetry writes occur only after the originating admin-plane action has already passed workspace membership, tenant entitlement, and capability checks. Aggregate reads remain system-plane only through the existing dashboard access rules. +**Organization**: Tasks are grouped by user story so the write path, read path, and privacy or isolation guardrails remain independently testable. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Prepare the bounded product-telemetry namespace and the local validation surfaces without widening scope. + +- [X] T001 Start the local Sail environment with `cd apps/platform && ./vendor/bin/sail up -d` (script: `apps/platform/vendor/bin/sail`) +- [X] T002 Create the bounded feature-local directories under `apps/platform/app/Support/ProductTelemetry/`, `apps/platform/tests/Unit/Support/ProductTelemetry/`, `apps/platform/tests/Feature/System/ProductTelemetry/`, `apps/platform/tests/Feature/Onboarding/`, `apps/platform/tests/Feature/SupportDiagnostics/`, `apps/platform/tests/Feature/Operations/`, and `apps/platform/tests/Feature/Reports/` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the single new tenant-owned telemetry ledger, the bounded event catalog and recorder, retention scaffolding, and the aggregate summary query before any source seam is instrumented. + +**Checkpoint**: The repo has one tenant-owned telemetry truth, one bounded recorder, one aggregate summary query, and one retention scaffold before story-specific source capture begins. + +- [X] T003 Create the telemetry migration in `apps/platform/database/migrations/*_create_product_usage_events_table.php` with non-null `workspace_id` and `tenant_id`, actor reference, event name, feature area, subject reference, safe metadata JSON, `occurred_at`, and bounded indexes for dashboard windows and source lookups +- [X] T004 Create the telemetry model in `apps/platform/app/Models/ProductUsageEvent.php` +- [X] T005 [P] Create the telemetry factory in `apps/platform/database/factories/ProductUsageEventFactory.php` +- [X] T006 [P] Create the bounded event catalog in `apps/platform/app/Support/ProductTelemetry/ProductUsageEventCatalog.php` +- [X] T007 Create the shared recorder and safe-metadata normalizer in `apps/platform/app/Support/ProductTelemetry/ProductTelemetryRecorder.php` +- [X] T008 Create the aggregate summary query for the system dashboard in `apps/platform/app/Support/ProductTelemetry/ProductTelemetrySummaryQuery.php`, including the five visible event-family counts plus active-workspace participation for the selected window +- [X] T009 [P] Add a config-backed 90-day retention rule in `apps/platform/config/tenantpilot.php`, scaffold the `tenantpilot:product-usage:prune` command in `apps/platform/app/Console/Commands/PruneProductUsageEventsCommand.php`, and register its daily scheduler entry in `apps/platform/routes/console.php` +- [X] T010 [P] Add unit coverage for the event catalog, recorder legality, safe metadata rules, and summary query in `apps/platform/tests/Unit/Support/ProductTelemetry/ProductUsageEventCatalogTest.php`, `apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetryRecorderTest.php`, `apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetrySafeMetadataTest.php`, and `apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetrySummaryQueryTest.php` +- [X] T011 Run the foundational unit suite with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductTelemetry/ProductUsageEventCatalogTest.php tests/Unit/Support/ProductTelemetry/ProductTelemetryRecorderTest.php tests/Unit/Support/ProductTelemetry/ProductTelemetrySafeMetadataTest.php tests/Unit/Support/ProductTelemetry/ProductTelemetrySummaryQueryTest.php` (tests: `apps/platform/tests/Unit/Support/ProductTelemetry/ProductUsageEventCatalogTest.php`) + +--- + +## Phase 3: User Story 1 — Record High-Signal Tenant Usage Centrally (Priority: P1) 🎯 MVP + +**Goal**: Record a bounded set of user-initiated tenant-bound milestones through one central contract instead of relying on scraped domain tables or local counters. + +**Independent Test**: Trigger each supported source milestone once and verify that exactly one tenant-bound telemetry row is written with the expected bounded event name, subject reference, and safe metadata. + +### Tests for User Story 1 + +- [X] T012 [P] [US1] Add onboarding capture coverage for checkpoint completion and the explicit non-capture rule for pre-tenant drafts in `apps/platform/tests/Feature/Onboarding/ProductTelemetryOnboardingCaptureTest.php` +- [X] T013 [P] [US1] Add support-diagnostics capture coverage for both tenant-dashboard and canonical run-detail entry points in `apps/platform/tests/Feature/SupportDiagnostics/ProductTelemetrySupportDiagnosticsCaptureTest.php` +- [X] T014 [P] [US1] Add operation-start capture coverage that records only user-initiated tenant-bound starts and ignores initiator-null or system-driven runs in `apps/platform/tests/Feature/Operations/ProductTelemetryOperationStartCaptureTest.php` +- [X] T015 [P] [US1] Add stored-report and review-pack capture coverage in `apps/platform/tests/Feature/Reports/ProductTelemetryReportCaptureTest.php` + +### Implementation for User Story 1 + +- [X] T016 [US1] Instrument onboarding checkpoint completion in `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php` so telemetry writes only after a real tenant-linked checkpoint transition succeeds +- [X] T017 [US1] Instrument the support-diagnostics open action on `apps/platform/app/Filament/Pages/TenantDashboard.php` and `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` using the shared recorder after the authorized action succeeds +- [X] T018 [US1] Instrument user-initiated tenant-bound operation starts in `apps/platform/app/Services/OperationRunService.php` without changing the existing run-start UX contract +- [X] T019 [US1] Instrument stored-report creation and review-pack generation request seams in `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesReportService.php`, `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`, and `apps/platform/app/Services/ReviewPackService.php` +- [X] T020 [US1] Run the US1 suite with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ProductTelemetryOnboardingCaptureTest.php tests/Feature/SupportDiagnostics/ProductTelemetrySupportDiagnosticsCaptureTest.php tests/Feature/Operations/ProductTelemetryOperationStartCaptureTest.php tests/Feature/Reports/ProductTelemetryReportCaptureTest.php` (tests: `apps/platform/tests/Feature/Onboarding/ProductTelemetryOnboardingCaptureTest.php`) + +--- + +## Phase 4: User Story 2 — See Bounded Adoption Signals On The System Dashboard (Priority: P1) + +**Goal**: Expose a read-only telemetry summary on the existing system dashboard so a platform operator can inspect recent adoption without a raw event browser. + +**Independent Test**: Seed telemetry rows across a bounded time window and verify that the system dashboard widget renders the expected aggregate counts, zero state, and access behavior. + +### Tests for User Story 2 + +- [X] T021 [P] [US2] Add widget coverage for the five visible event families, active-workspace participation, aggregate counts, zero state, and time-window behavior in `apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryDashboardWidgetTest.php` +- [X] T022 [P] [US2] Add system-plane authorization coverage proving that only existing dashboard-eligible platform users can view aggregate telemetry in `apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryAuthorizationTest.php` + +### Implementation for User Story 2 + +- [X] T023 [US2] Create the read-only telemetry widget in `apps/platform/app/Filament/System/Widgets/ProductTelemetryKpis.php` with five visible family counters, active-workspace participation, and an explicit zero-state summary +- [X] T024 [US2] Register the widget on `apps/platform/app/Filament/System/Pages/Dashboard.php` and reuse the existing `SystemConsoleWindow` selection for telemetry windows +- [X] T025 [US2] Keep the widget aggregate-only and avoid any raw row list, tenant drilldown, or new dashboard action group in the first slice +- [X] T026 [US2] Run the US2 suite with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ProductTelemetry/ProductTelemetryDashboardWidgetTest.php tests/Feature/System/ProductTelemetry/ProductTelemetryAuthorizationTest.php` (tests: `apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryDashboardWidgetTest.php`) + +--- + +## Phase 5: User Story 3 — Keep Telemetry Private, Tenant-Bound, And Cheap (Priority: P2) + +**Goal**: Prevent the feature from becoming a second audit log, a page-view tracker, or a tenant-leaking analytics system. + +**Independent Test**: Inspect stored telemetry rows and the prune path to confirm only safe tenant-bound metadata is stored and that old rows can be deleted without touching domain truth. + +### Tests for User Story 3 + +- [X] T027 [P] [US3] Extend the source-capture feature tests in `apps/platform/tests/Feature/Onboarding/ProductTelemetryOnboardingCaptureTest.php`, `apps/platform/tests/Feature/SupportDiagnostics/ProductTelemetrySupportDiagnosticsCaptureTest.php`, `apps/platform/tests/Feature/Operations/ProductTelemetryOperationStartCaptureTest.php`, and `apps/platform/tests/Feature/Reports/ProductTelemetryReportCaptureTest.php` to assert emitted rows never contain forbidden free text, payload content, or email storage +- [X] T028 [P] [US3] Add retention and prune coverage in `apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryRetentionTest.php` +- [X] T029 [P] [US3] Add a regression guard proving that passive render paths do not emit telemetry, disallowed stores such as `AuditLog` remain untouched, and no raw telemetry viewer exposure is introduced in `apps/platform/tests/Feature/System/ProductTelemetry/NoAdHocTelemetryBypassTest.php` + +### Implementation for User Story 3 + +- [X] T030 [US3] Harden the source instrumentation in `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Services/OperationRunService.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesReportService.php`, `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`, and `apps/platform/app/Services/ReviewPackService.php` so each seam passes only bounded IDs, enums, booleans, and timestamps into the foundational recorder +- [X] T031 [US3] Implement the prune semantics inside `apps/platform/app/Console/Commands/PruneProductUsageEventsCommand.php` so the foundational command deletes only `ProductUsageEvent` rows older than the configured window and leaves source-of-truth tables untouched +- [X] T032 [US3] Add any necessary model or factory support for retention and safe-metadata assertions in `apps/platform/app/Models/ProductUsageEvent.php` and `apps/platform/database/factories/ProductUsageEventFactory.php` +- [X] T033 [US3] Run the US3 suite with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductTelemetry/ProductTelemetrySafeMetadataTest.php tests/Feature/System/ProductTelemetry/ProductTelemetryRetentionTest.php tests/Feature/System/ProductTelemetry/NoAdHocTelemetryBypassTest.php` (tests: `apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryRetentionTest.php`) + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Lock down the bounded catalog, formatting, and the narrow validation suite before implementation close-out. + +- [X] T034 [P] Confirm the bounded first-slice event catalog, the five visible dashboard labels, and the active-workspace participation metric stay aligned in `apps/platform/app/Support/ProductTelemetry/ProductUsageEventCatalog.php` and `apps/platform/app/Filament/System/Widgets/ProductTelemetryKpis.php` +- [X] T035 Run formatting on touched platform files with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` (target: `apps/platform/`) +- [X] T036 Run the full narrow validation suite with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductTelemetry tests/Feature/System/ProductTelemetry tests/Feature/Onboarding/ProductTelemetryOnboardingCaptureTest.php tests/Feature/SupportDiagnostics/ProductTelemetrySupportDiagnosticsCaptureTest.php tests/Feature/Operations/ProductTelemetryOperationStartCaptureTest.php tests/Feature/Reports/ProductTelemetryReportCaptureTest.php` + +--- + +## Dependencies & Execution Order + +### User Story Dependency Graph + +```text +Phase 1 (Setup) + ↓ +Phase 2 (Foundation: telemetry ledger + recorder + summary query + retention) + ↓ +US1 (source capture at onboarding / diagnostics / operations / reports) ─┐ + ├─→ US3 (privacy / retention / anti-bypass guards) +US2 (system dashboard aggregate visibility) ──────────────────────────────┘ +``` + +### Parallel Opportunities + +- Foundational tasks marked `[P]` can run in parallel once the event-table shape is agreed. +- US1 source-capture tests can be authored in parallel because onboarding, support diagnostics, operations, and reports touch different seams. +- US2 widget and authorization tests can run in parallel while the widget implementation is isolated to the system dashboard. +- US3 privacy, retention, and anti-bypass guard tasks can parallelize after the recorder contract is fixed. + +--- + +## Parallel Example: User Story 1 + +```bash +Task: "Add onboarding capture coverage in apps/platform/tests/Feature/Onboarding/ProductTelemetryOnboardingCaptureTest.php" +Task: "Add support-diagnostics capture coverage in apps/platform/tests/Feature/SupportDiagnostics/ProductTelemetrySupportDiagnosticsCaptureTest.php" +Task: "Add operation-start capture coverage in apps/platform/tests/Feature/Operations/ProductTelemetryOperationStartCaptureTest.php" +Task: "Add stored-report and review-pack capture coverage in apps/platform/tests/Feature/Reports/ProductTelemetryReportCaptureTest.php" +``` + +--- + +## Parallel Example: User Story 2 + +```bash +Task: "Add widget coverage in apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryDashboardWidgetTest.php" +Task: "Add dashboard authorization coverage in apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryAuthorizationTest.php" +Task: "Create the widget in apps/platform/app/Filament/System/Widgets/ProductTelemetryKpis.php" +``` + +--- + +## Parallel Example: User Story 3 + +```bash +Task: "Add metadata guard coverage in apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetrySafeMetadataTest.php" +Task: "Add retention coverage in apps/platform/tests/Feature/System/ProductTelemetry/ProductTelemetryRetentionTest.php" +Task: "Add anti-bypass guard coverage in apps/platform/tests/Feature/System/ProductTelemetry/NoAdHocTelemetryBypassTest.php" +Task: "Implement the prune command in apps/platform/app/Console/Commands/PruneProductUsageEventsCommand.php" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1) + +1. Complete Phase 1 and Phase 2. +2. Deliver the bounded write path for real source milestones in US1. +3. Validate that the ledger remains tenant-bound and safe before surfacing it anywhere. + +### Incremental Delivery + +1. US1 introduces the central telemetry ledger and real source capture from existing services and actions. +2. US2 surfaces the new truth through one read-only system dashboard widget. +3. US3 adds retention, privacy guardrails, and anti-bypass regression protection. +4. Phase 6 runs formatting and the narrow validation suite only. \ No newline at end of file -- 2.45.2 From bf43e5584834e9a3ec50cb028fa08520072690c6 Mon Sep 17 00:00:00 2001 From: ahmido Date: Mon, 27 Apr 2026 00:09:46 +0000 Subject: [PATCH 18/36] feat(onboarding): decision-first verify-step & contextual-help callout fix (#282) This PR implements the onboarding verify-step changes and ProductKnowledge contextual-help fixes: - Hide the top-level "Permission diagnostics" when a stored verification report exists. - Move permission details to "Supporting evidence" / "View required permissions" and into stored report technical details. - Rename "Current checkpoint" to "Step" in onboarding readiness. - Rename the inner verification card title to "Stored verification details" to avoid duplicate headings. - Keep "Grant admin consent" as primary CTA when admin consent is the dominant blocker by deriving the CTA from the verification primary reason. - Replace the custom Safe Next Action with Filament `Callout` for correct dark-mode styling. - Add/adjust focused feature tests proving the above behaviors. Verification: - Tests: 36 passed (173 assertions) locally. - Pint: pass. Created from local session branch `244-product-knowledge-contextual-help-session-1777248340`. Please review and merge into `dev` when ready. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/282 --- .../ManagedTenantOnboardingWizard.php | 146 +++++- .../ContextualHelpCatalog.php | 263 +++++++++++ .../ContextualHelpResolver.php | 427 ++++++++++++++++++ .../SupportDiagnosticBundleBuilder.php | 61 +++ .../contextual-help.blade.php | 80 ++++ ...t-onboarding-verification-report.blade.php | 33 +- .../support-diagnostic-bundle.blade.php | 5 + .../ManagedTenantOnboardingWizardTest.php | 35 +- .../ProductKnowledgeOnboardingHelpTest.php | 265 +++++++++++ .../ProductKnowledgeAuthorizationTest.php | 133 ++++++ ...ductKnowledgeSupportDiagnosticHelpTest.php | 162 +++++++ .../ContextualHelpCatalogTest.php | 85 ++++ .../ContextualHelpFallbackTest.php | 51 +++ .../ContextualHelpResolverTest.php | 103 +++++ .../checklists/requirements.md | 42 ++ .../plan.md | 199 ++++++++ .../spec.md | 283 ++++++++++++ .../tasks.md | 134 ++++++ 18 files changed, 2455 insertions(+), 52 deletions(-) create mode 100644 apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php create mode 100644 apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php create mode 100644 apps/platform/resources/views/filament/components/product-knowledge/contextual-help.blade.php create mode 100644 apps/platform/tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php create mode 100644 apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php create mode 100644 apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php create mode 100644 apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php create mode 100644 apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php create mode 100644 apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php create mode 100644 specs/244-product-knowledge-contextual-help/checklists/requirements.md create mode 100644 specs/244-product-knowledge-contextual-help/plan.md create mode 100644 specs/244-product-knowledge-contextual-help/spec.md create mode 100644 specs/244-product-knowledge-contextual-help/tasks.md diff --git a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index c482e536..1bbc51ab 100644 --- a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -4,6 +4,7 @@ namespace App\Filament\Pages\Workspaces; +use BackedEnum; use App\Exceptions\Onboarding\OnboardingDraftConflictException; use App\Exceptions\Onboarding\OnboardingDraftImmutableException; use App\Filament\Pages\TenantDashboard; @@ -51,6 +52,7 @@ use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\ProductKnowledge\ContextualHelpResolver; use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderReasonCodes; @@ -994,6 +996,7 @@ private function routeBoundReadinessSchema(): array } $payload = $this->onboardingReadinessPayload($draft); + $primaryNextAction = $this->readinessPrimaryNextActionComponent($payload, 'route_bound_readiness'); $schema = [ Section::make('Onboarding readiness') @@ -1001,7 +1004,7 @@ private function routeBoundReadinessSchema(): array ->compact() ->columns(2) ->schema([ - Text::make('Current checkpoint') + Text::make('Step') ->color('gray'), Text::make($payload['checkpoint']['current_checkpoint_label'] ?? '—') ->badge() @@ -1021,9 +1024,7 @@ private function routeBoundReadinessSchema(): array Text::make($payload['freshness']['note']), Text::make('Primary next action') ->color('gray'), - Text::make($payload['next_action']['label']) - ->badge() - ->color($this->readinessNextActionColor($payload['next_action']['kind'])), + $primaryNextAction, ]), ]; @@ -1064,8 +1065,14 @@ private function draftCompactReadinessSchema(TenantOnboardingSession $draft): ar private function readinessSupportingEvidenceSchema(array $payload, string $keyPrefix): array { $links = is_array($payload['supporting_links'] ?? null) ? $payload['supporting_links'] : []; + $assist = is_array($payload['verification_assist'] ?? null) ? $payload['verification_assist'] : []; + $showAssist = (bool) ($assist['is_visible'] ?? false); + $permissions = is_array($payload['permissions'] ?? null) ? $payload['permissions'] : []; + $requiredPermissionsUrl = is_string($permissions['required_permissions_url'] ?? null) + ? $permissions['required_permissions_url'] + : null; - if ($links === []) { + if ($links === [] && ! ($showAssist && $requiredPermissionsUrl !== null && $requiredPermissionsUrl !== '')) { return []; } @@ -1089,13 +1096,20 @@ private function readinessSupportingEvidenceSchema(array $payload, string $keyPr ->url($url); } + if ($showAssist && $requiredPermissionsUrl !== null && $requiredPermissionsUrl !== '') { + $actions[] = Action::make($keyPrefix.'_required_permissions_assist') + ->label('View required permissions') + ->color('gray') + ->url($requiredPermissionsUrl); + } + if ($actions === []) { return []; } return [ Section::make('Supporting evidence') - ->description('Open canonical operation detail when deeper diagnostics are needed.') + ->description('Open canonical operation detail or secondary permission evidence when deeper diagnostics are needed.') ->compact() ->schema([ SchemaActions::make($actions)->key($keyPrefix.'_supporting_evidence_actions'), @@ -1115,14 +1129,16 @@ private function readinessPermissionDiagnosticsSchema(array $payload, string $ke return []; } + if ((bool) ($payload['verification']['has_report'] ?? false)) { + return []; + } + $counts = is_array($permissions['counts'] ?? null) ? $permissions['counts'] : []; $missingApplication = (int) ($counts['missing_application'] ?? 0); $missingDelegated = (int) ($counts['missing_delegated'] ?? 0); $errors = (int) ($counts['error'] ?? 0); - $assist = is_array($payload['verification_assist'] ?? null) ? $payload['verification_assist'] : []; - $isVisible = (bool) ($assist['is_visible'] ?? false); - if ($missingApplication + $missingDelegated + $errors === 0 && ! $isVisible) { + if ($missingApplication + $missingDelegated + $errors === 0) { return []; } @@ -1177,7 +1193,7 @@ private function readinessPermissionDiagnosticsSchema(array $payload, string $ke * draft: array{id: int, tenant_name: string, stage_label: string, draft_status_label: string, started_by: string, updated_by: string, last_updated_human: string}, * checkpoint: array{current_checkpoint: string|null, current_checkpoint_label: string|null, last_completed_checkpoint: string|null, lifecycle_state: string, lifecycle_label: string}, * provider_summary: array|null, - * verification: array{status: string, status_label: string, run_id: int|null, run_url: string|null, is_active: bool, matches_selected_connection: bool|null, overall: string|null}, + * verification: array{status: string, status_label: string, run_id: int|null, run_url: string|null, is_active: bool, has_report: bool, matches_selected_connection: bool|null, overall: string|null}, * verification_assist: array{is_visible: bool, reason: string}, * permissions: array{overall: string|null, counts: array, freshness: array{last_refreshed_at: string|null, is_stale: bool}, missing_permissions: array{application: list, delegated: list}, required_permissions_url: string|null}|null, * freshness: array{connection_recently_updated: bool, verification_mismatch: bool, permission_last_refreshed_at: string|null, permission_data_is_stale: bool, note: string}, @@ -1218,6 +1234,9 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr $permissions = $tenant instanceof Tenant ? $this->readinessPermissionOverview($tenant) : null; $verificationReport = $verificationRun instanceof OperationRun ? VerificationReportViewer::report($verificationRun) : null; $verificationReport = is_array($verificationReport) ? $verificationReport : null; + $verificationPrimaryReasonCode = $verificationReport !== null + ? app(ContextualHelpResolver::class)->primaryReasonCodeFromVerificationReport($verificationReport) + : null; $permissionFreshness = is_array($permissions['freshness'] ?? null) ? $permissions['freshness'] : [ 'last_refreshed_at' => null, 'is_stale' => true, @@ -1237,6 +1256,9 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr verificationMismatch: $verificationMismatch, ); + $reasonCode = is_string($snapshot['reason_code'] ?? null) ? $snapshot['reason_code'] : null; + $blockingReasonCode = is_string($snapshot['blocking_reason_code'] ?? null) ? $snapshot['blocking_reason_code'] : null; + return [ 'draft' => [ 'id' => (int) $draft->getKey(), @@ -1263,6 +1285,7 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr ? OperationRunLinks::tenantlessView($verificationRun) : null, 'is_active' => $verificationRun instanceof OperationRun && $verificationRun->status !== OperationRunStatus::Completed->value, + 'has_report' => $verificationReport !== null, 'matches_selected_connection' => $verificationMatchesSelectedConnection, 'overall' => $verificationRun instanceof OperationRun ? $this->readinessVerificationOverall($verificationRun, $verificationReport) @@ -1286,8 +1309,8 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr ), ], 'blocker' => [ - 'reason_code' => is_string($snapshot['reason_code'] ?? null) ? $snapshot['reason_code'] : null, - 'blocking_reason_code' => is_string($snapshot['blocking_reason_code'] ?? null) ? $snapshot['blocking_reason_code'] : null, + 'reason_code' => $reasonCode, + 'blocking_reason_code' => $blockingReasonCode, 'operator_summary' => $readinessSummary, ], 'next_action' => $this->readinessNextAction( @@ -1297,6 +1320,7 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr verificationRun: $verificationRun, verificationStatus: $verificationStatus, permissions: $permissions, + blockerReasonCode: $verificationPrimaryReasonCode ?? $blockingReasonCode ?? $reasonCode, connectionRecentlyUpdated: $connectionRecentlyUpdated, verificationMismatch: $verificationMismatch, supportingLinks: $supportingLinks, @@ -1374,6 +1398,35 @@ private function readinessNextActionColor(string $kind): string }; } + /** + * @param array $payload + */ + private function readinessPrimaryNextActionComponent(array $payload, string $keyPrefix): \Filament\Schemas\Components\Component + { + $nextAction = is_array($payload['next_action'] ?? null) ? $payload['next_action'] : []; + $label = is_string($nextAction['label'] ?? null) && trim((string) $nextAction['label']) !== '' + ? trim((string) $nextAction['label']) + : 'Continue onboarding'; + $kind = is_string($nextAction['kind'] ?? null) ? $nextAction['kind'] : 'gray'; + $url = is_string($nextAction['url'] ?? null) && trim((string) $nextAction['url']) !== '' + ? trim((string) $nextAction['url']) + : null; + + if ($url !== null) { + return SchemaActions::make([ + Action::make($keyPrefix.'_primary_next_action') + ->label($label) + ->color($this->readinessNextActionColor($kind)) + ->url($url) + ->openUrlInNewTab(str_starts_with($url, 'http://') || str_starts_with($url, 'https://')), + ])->key($keyPrefix.'_primary_next_action'); + } + + return Text::make($label) + ->badge() + ->color($this->readinessNextActionColor($kind)); + } + private function readinessProviderConnection(TenantOnboardingSession $draft): ?ProviderConnection { $state = is_array($draft->state) ? $draft->state : []; @@ -1407,8 +1460,8 @@ private function readinessProviderSummary(?ProviderConnection $connection): ?arr return [ 'provider' => (string) $connection->provider, 'target_scope' => [], - 'consent_state' => (string) $connection->consent_status, - 'verification_state' => (string) $connection->verification_status, + 'consent_state' => $this->stringValue($connection->consent_status), + 'verification_state' => $this->stringValue($connection->verification_status), 'readiness_summary' => 'Target scope needs review', 'target_scope_summary' => 'Target scope needs review', 'contextual_identity_line' => null, @@ -1614,6 +1667,7 @@ private function readinessNextAction( ?OperationRun $verificationRun, string $verificationStatus, ?array $permissions, + ?string $blockerReasonCode, bool $connectionRecentlyUpdated, bool $verificationMismatch, array $supportingLinks, @@ -1639,7 +1693,7 @@ private function readinessNextAction( if ($consentState !== ProviderConsentStatus::Granted->value) { return $this->readinessAction( - label: 'Grant consent', + label: 'Grant admin consent', kind: 'grant_consent', url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($draft->tenant) : null, ); @@ -1647,6 +1701,18 @@ private function readinessNextAction( $permissionOverall = is_string($permissions['overall'] ?? null) ? $permissions['overall'] : null; + if (in_array($blockerReasonCode, [ + ProviderReasonCodes::ProviderConsentMissing, + ProviderReasonCodes::ProviderConsentFailed, + ProviderReasonCodes::ProviderConsentRevoked, + ], true)) { + return $this->readinessAction( + label: 'Grant admin consent', + kind: 'grant_consent', + url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($draft->tenant) : null, + ); + } + if ($permissionOverall === VerificationReportOverall::Blocked->value) { return $this->readinessAction( label: 'Review permissions', @@ -2777,6 +2843,7 @@ private function verificationReportViewData(): array 'acknowledgements' => [], 'surface' => [], 'redactionNotes' => [], + 'contextualHelp' => null, 'assistVisibility' => $assistVisibility, 'assistActionName' => 'wizardVerificationRequiredPermissionsAssist', 'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails', @@ -2786,6 +2853,7 @@ private function verificationReportViewData(): array $report = VerificationReportViewer::report($run); $fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null; + $contextualHelp = is_array($report) ? $this->verificationContextualHelp($report, $run) : null; $changeIndicator = VerificationReportChangeIndicator::forRun($run); $previousRunUrl = $this->verificationPreviousRunUrl($changeIndicator); @@ -2872,6 +2940,7 @@ private function verificationReportViewData(): array 'acknowledgements' => $acknowledgements, 'surface' => $surface, 'redactionNotes' => VerificationReportViewer::redactionNotes($report), + 'contextualHelp' => $contextualHelp, 'assistVisibility' => $assistVisibility, 'assistActionName' => 'wizardVerificationRequiredPermissionsAssist', 'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails', @@ -2879,6 +2948,40 @@ private function verificationReportViewData(): array ]; } + /** + * @param array $verificationReport + * @return array|null + */ + private function verificationContextualHelp(array $verificationReport, OperationRun $run): ?array + { + $tenant = $this->managedTenant; + + if (! $tenant instanceof Tenant) { + return null; + } + + $resolver = app(ContextualHelpResolver::class); + $reasonCode = $resolver->primaryReasonCodeFromVerificationReport($verificationReport); + $topicKey = $resolver->topicKeyForOnboardingVerification( + reasonCode: $reasonCode, + isVerificationStale: $this->verificationRunIsStaleForSelectedConnection(), + verificationOverall: is_string(data_get($verificationReport, 'summary.overall')) + ? (string) data_get($verificationReport, 'summary.overall') + : null, + runOutcome: is_string($run->outcome) ? (string) $run->outcome : null, + ); + + if ($topicKey === null) { + return null; + } + + return $resolver->tryResolve($topicKey, [ + 'tenant' => $tenant, + 'reason_code' => $reasonCode, + 'surface' => 'onboarding', + ]); + } + public function wizardVerificationRequiredPermissionsAssistAction(): Action { return Action::make('wizardVerificationRequiredPermissionsAssist') @@ -4507,6 +4610,19 @@ private function completionSummaryConnectionSummary(): string return sprintf('%s - %s', $label, $detail); } + private function stringValue(mixed $value): string + { + if ($value instanceof BackedEnum) { + return (string) $value->value; + } + + if (is_string($value) || is_int($value) || is_float($value) || is_bool($value)) { + return (string) $value; + } + + return ''; + } + private function completionSummaryVerificationDetail(): string { $counts = $this->verificationReportCounts(); diff --git a/apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php b/apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php new file mode 100644 index 00000000..0fb72b18 --- /dev/null +++ b/apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php @@ -0,0 +1,263 @@ + + */ + public function keys(): array + { + return array_keys($this->definitions()); + } + + /** + * @return array, + * headline: string, + * short_explanation: string, + * troubleshooting_steps: list, + * safe_next_action: string, + * glossary_terms: list, + * docs_links: list + * }> + */ + public function definitions(): array + { + return [ + self::ADMIN_CONSENT_REQUIRED => [ + 'topic_key' => self::ADMIN_CONSENT_REQUIRED, + 'surface_families' => ['onboarding', 'support_diagnostics'], + 'headline' => 'Admin consent required', + 'short_explanation' => 'This workflow is blocked until admin consent is granted for the current provider connection.', + 'troubleshooting_steps' => [ + 'Grant admin consent for the current provider connection.', + 'Re-run verification or reopen support diagnostics after consent completes.', + ], + 'safe_next_action' => 'Grant admin consent and re-run verification.', + 'glossary_terms' => ['tenant', 'workspace', 'provider connection'], + 'docs_links' => [ + [ + 'label' => 'Grant admin consent', + 'kind' => 'action', + 'resolver' => self::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY, + ], + [ + 'label' => 'Admin consent guide', + 'kind' => 'docs', + 'url' => RequiredPermissionsLinks::adminConsentGuideUrl(), + ], + ], + ], + self::REQUIRED_PERMISSIONS_MISSING => [ + 'topic_key' => self::REQUIRED_PERMISSIONS_MISSING, + 'surface_families' => ['onboarding', 'support_diagnostics'], + 'headline' => 'Required permissions missing', + 'short_explanation' => 'The provider app is missing one or more required permissions for the current workflow.', + 'troubleshooting_steps' => [ + 'Review the required permissions matrix for the current tenant.', + 'Refresh verification after the missing permissions are granted.', + ], + 'safe_next_action' => 'Open required permissions and confirm the missing grants.', + 'glossary_terms' => ['tenant', 'workspace', 'provider connection'], + 'docs_links' => [ + [ + 'label' => 'Open required permissions', + 'kind' => 'action', + 'resolver' => self::LINK_RESOLVER_REQUIRED_PERMISSIONS, + ], + ], + ], + self::CONNECTION_UNHEALTHY => [ + 'topic_key' => self::CONNECTION_UNHEALTHY, + 'surface_families' => ['onboarding', 'support_diagnostics'], + 'headline' => 'Provider connection needs review', + 'short_explanation' => 'The provider connection is degraded or unavailable, so the current result cannot be treated as healthy.', + 'troubleshooting_steps' => [ + 'Review the latest provider connection health signal.', + 'Retry the workflow after the provider connection is healthy again.', + ], + 'safe_next_action' => 'Review the provider connection before retrying.', + 'glossary_terms' => ['tenant', 'workspace', 'provider connection'], + 'docs_links' => [], + ], + self::VERIFICATION_STALE => [ + 'topic_key' => self::VERIFICATION_STALE, + 'surface_families' => ['onboarding'], + 'headline' => 'Verification result is stale', + 'short_explanation' => 'The most recent verification result is too old or mismatched to trust for the current onboarding decision.', + 'troubleshooting_steps' => [ + 'Run verification again for the currently selected provider connection.', + 'Use the refreshed result before continuing onboarding.', + ], + 'safe_next_action' => 'Refresh verification before continuing onboarding.', + 'glossary_terms' => ['tenant', 'workspace', 'verification'], + 'docs_links' => [], + ], + self::VERIFICATION_FAILED => [ + 'topic_key' => self::VERIFICATION_FAILED, + 'surface_families' => ['onboarding', 'support_diagnostics'], + 'headline' => 'Verification failed', + 'short_explanation' => 'The latest verification run did not produce a decision-grade result for the current tenant context.', + 'troubleshooting_steps' => [ + 'Review the blocking reason before retrying verification.', + 'Confirm the prerequisite is fixed, then run verification again.', + ], + 'safe_next_action' => 'Review the blocking reason and retry verification.', + 'glossary_terms' => ['tenant', 'workspace', 'verification'], + 'docs_links' => [], + ], + self::DIAGNOSTIC_EVIDENCE_INCOMPLETE => [ + 'topic_key' => self::DIAGNOSTIC_EVIDENCE_INCOMPLETE, + 'surface_families' => ['support_diagnostics'], + 'headline' => 'Diagnostic evidence is incomplete', + 'short_explanation' => 'Support diagnostics can summarize the issue, but the available evidence is not complete enough for a final conclusion.', + 'troubleshooting_steps' => [ + 'Review the available evidence and supporting references in the current support context.', + 'Collect a fresh verification or operation result before making a final decision.', + ], + 'safe_next_action' => 'Collect a fresher or more complete diagnostic signal.', + 'glossary_terms' => ['support diagnostics', 'evidence', 'workspace'], + 'docs_links' => [], + ], + self::RETRYABLE_PROVIDER_FAILURE => [ + 'topic_key' => self::RETRYABLE_PROVIDER_FAILURE, + 'surface_families' => ['support_diagnostics'], + 'headline' => 'Provider failure looks retryable', + 'short_explanation' => 'The current provider issue appears temporary, so the next safe step is to retry once the dependency recovers.', + 'troubleshooting_steps' => [ + 'Confirm the provider dependency has recovered.', + 'Retry the workflow after the provider-side issue clears.', + ], + 'safe_next_action' => 'Retry after the provider dependency recovers.', + 'glossary_terms' => ['support diagnostics', 'provider connection', 'workspace'], + 'docs_links' => [], + ], + self::MANUAL_HANDOFF_REQUIRED => [ + 'topic_key' => self::MANUAL_HANDOFF_REQUIRED, + 'surface_families' => ['support_diagnostics'], + 'headline' => 'Manual support handoff required', + 'short_explanation' => 'TenantPilot can summarize the current issue, but a human support handoff is still required for the next step.', + 'troubleshooting_steps' => [ + 'Review the current references before handing off the case.', + 'Capture the safe next step and the supporting evidence for the receiving operator.', + ], + 'safe_next_action' => 'Hand off the case with the current diagnostic summary and supporting references.', + 'glossary_terms' => ['support diagnostics', 'tenant', 'workspace'], + 'docs_links' => [], + ], + ]; + } + + /** + * @return array{ + * topic_key: string, + * surface_families: list, + * headline: string, + * short_explanation: string, + * troubleshooting_steps: list, + * safe_next_action: string, + * glossary_terms: list, + * docs_links: list + * } + */ + public function definition(string $topicKey): array + { + $definition = $this->definitions()[trim($topicKey)] ?? null; + + if (! is_array($definition)) { + throw new InvalidArgumentException('Unknown contextual help topic'); + } + + return $definition; + } + + /** + * @return array{ + * version: int, + * topic_count: int, + * topics: list, + * headline: string, + * short_explanation: string, + * troubleshooting_steps: list, + * safe_next_action: string, + * glossary_terms: list, + * docs_links: list + * }> + * } + */ + public function knowledgeSource(): array + { + $topics = array_values(array_map( + fn (array $definition): array => [ + 'topic_key' => $definition['topic_key'], + 'surface_families' => $definition['surface_families'], + 'headline' => $definition['headline'], + 'short_explanation' => $definition['short_explanation'], + 'troubleshooting_steps' => $definition['troubleshooting_steps'], + 'safe_next_action' => $definition['safe_next_action'], + 'glossary_terms' => $definition['glossary_terms'], + 'docs_links' => array_values(array_map( + static fn (array $link): array => [ + 'label' => (string) $link['label'], + 'kind' => (string) ($link['kind'] ?? 'url'), + 'url' => isset($link['url']) && is_string($link['url']) ? $link['url'] : null, + 'resolver' => isset($link['resolver']) && is_string($link['resolver']) ? $link['resolver'] : null, + ], + $definition['docs_links'], + )), + ], + $this->definitions(), + )); + + return [ + 'version' => 1, + 'topic_count' => count($topics), + 'topics' => $topics, + ]; + } +} diff --git a/apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php b/apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php new file mode 100644 index 00000000..4f0e41a4 --- /dev/null +++ b/apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php @@ -0,0 +1,427 @@ + $context + * @return array{ + * topic_key: string, + * surface_families: list, + * headline: string, + * short_explanation: string, + * troubleshooting_steps: list, + * safe_next_action: string, + * docs_links: list, + * canonical_terms: list, + * reason_label: ?string, + * diagnostic_code: ?string, + * operator_summary: ?array{ + * primaryReason: string, + * nextActionText: string, + * diagnosticsAvailable: bool, + * diagnosticsSummary: ?string + * } + * } + */ + public function resolve(string $topicKey, array $context = []): array + { + $definition = $this->catalog->definition($topicKey); + $reasonEnvelope = $this->providerReasonEnvelope($context); + $operatorSummary = $this->operatorSummary($context); + + return [ + 'topic_key' => $definition['topic_key'], + 'surface_families' => $definition['surface_families'], + 'headline' => $this->firstNonEmpty( + $this->stringOrNull($context['headline'] ?? null), + $definition['headline'], + ), + 'short_explanation' => $this->firstNonEmpty( + $this->stringOrNull($context['short_explanation'] ?? null), + $this->reasonPresenter->shortExplanation($reasonEnvelope), + $definition['short_explanation'], + ), + 'troubleshooting_steps' => $this->troubleshootingSteps($definition['troubleshooting_steps'], $context), + 'safe_next_action' => $this->firstNonEmpty( + $this->stringOrNull($context['safe_next_action'] ?? null), + $operatorSummary['nextActionText'] ?? null, + $definition['safe_next_action'], + ), + 'docs_links' => $this->resolveLinks($definition['docs_links'], $context), + 'canonical_terms' => $this->canonicalTerms($definition['glossary_terms']), + 'reason_label' => $this->reasonPresenter->primaryLabel($reasonEnvelope), + 'diagnostic_code' => $this->reasonPresenter->diagnosticCode($reasonEnvelope), + 'operator_summary' => $operatorSummary, + ]; + } + + /** + * @param array $context + * @return array{ + * topic_key: string, + * surface_families: list, + * headline: string, + * short_explanation: string, + * troubleshooting_steps: list, + * safe_next_action: string, + * docs_links: list, + * canonical_terms: list, + * reason_label: ?string, + * diagnostic_code: ?string, + * operator_summary: ?array{ + * primaryReason: string, + * nextActionText: string, + * diagnosticsAvailable: bool, + * diagnosticsSummary: ?string + * } + * }|null + */ + public function tryResolve(?string $topicKey, array $context = []): ?array + { + if (! is_string($topicKey) || trim($topicKey) === '') { + return null; + } + + try { + return $this->resolve($topicKey, $context); + } catch (InvalidArgumentException) { + return null; + } + } + + /** + * @return array{ + * version: int, + * topic_count: int, + * topics: list, + * headline: string, + * short_explanation: string, + * troubleshooting_steps: list, + * safe_next_action: string, + * glossary_terms: list, + * docs_links: list + * }> + * } + */ + public function knowledgeSource(): array + { + return $this->catalog->knowledgeSource(); + } + + /** + * @param array|null $verificationReport + */ + public function primaryReasonCodeFromVerificationReport(?array $verificationReport): ?string + { + foreach ($this->relevantChecks($verificationReport) as $check) { + $reasonCode = $check['reason_code'] ?? null; + + if (is_string($reasonCode) && trim($reasonCode) !== '') { + return trim($reasonCode); + } + } + + return null; + } + + public function topicKeyForOnboardingVerification( + ?string $reasonCode, + bool $isVerificationStale, + ?string $verificationOverall, + ?string $runOutcome, + ): ?string { + if ($isVerificationStale) { + return ContextualHelpCatalog::VERIFICATION_STALE; + } + + $reasonTopicKey = $this->onboardingTopicKeyForReason($reasonCode); + + if ($reasonTopicKey !== null) { + return $reasonTopicKey; + } + + return $this->isVerificationFailure($verificationOverall, $runOutcome) + ? ContextualHelpCatalog::VERIFICATION_FAILED + : null; + } + + public function topicKeyForSupportDiagnostics( + ?string $reasonCode, + bool $hasIncompleteEvidence, + ?string $runOutcome, + ): ?string { + $reasonTopicKey = $this->supportDiagnosticsTopicKeyForReason($reasonCode); + + if ($reasonTopicKey !== null) { + return $reasonTopicKey; + } + + if (is_string($reasonCode) && trim($reasonCode) !== '') { + return $reasonCode === ProviderReasonCodes::UnknownError + ? ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED + : null; + } + + if ($this->isVerificationFailure(null, $runOutcome)) { + return ContextualHelpCatalog::VERIFICATION_FAILED; + } + + return $hasIncompleteEvidence + ? ContextualHelpCatalog::DIAGNOSTIC_EVIDENCE_INCOMPLETE + : null; + } + + /** + * @param array $context + */ + private function providerReasonEnvelope(array $context): ?ReasonResolutionEnvelope + { + $tenant = $context['tenant'] ?? null; + $reasonCode = $context['reason_code'] ?? null; + $connection = $context['connection'] ?? null; + $surface = $this->stringOrNull($context['surface'] ?? null) ?? 'detail'; + + if (! $tenant instanceof Tenant || ! is_string($reasonCode) || trim($reasonCode) === '') { + return null; + } + + return $this->reasonPresenter->forProviderReason( + tenant: $tenant, + reasonCode: trim($reasonCode), + connection: $connection instanceof ProviderConnection ? $connection : null, + surface: $surface, + ); + } + + /** + * @param array $context + * @return array{ + * primaryReason: string, + * nextActionText: string, + * diagnosticsAvailable: bool, + * diagnosticsSummary: ?string + * }|null + */ + private function operatorSummary(array $context): ?array + { + $truth = $context['artifact_truth'] ?? null; + + if (! $truth instanceof ArtifactTruthEnvelope) { + return null; + } + + return $this->operatorExplanationBuilder->compressionSummaryInputs($truth); + } + + /** + * @param array|null $verificationReport + * @return list> + */ + private function relevantChecks(?array $verificationReport): array + { + $checks = is_array($verificationReport['checks'] ?? null) + ? $verificationReport['checks'] + : []; + + return array_values(array_filter($checks, static function (mixed $check): bool { + if (! is_array($check)) { + return false; + } + + $status = $check['status'] ?? null; + + return is_string($status) + && ! in_array($status, ['pass', 'skip', 'running'], true); + })); + } + + private function onboardingTopicKeyForReason(?string $reasonCode): ?string + { + return match ($reasonCode) { + ProviderReasonCodes::ProviderConsentMissing, + ProviderReasonCodes::ProviderConsentFailed, + ProviderReasonCodes::ProviderConsentRevoked => ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED, + ProviderReasonCodes::ProviderPermissionMissing, + ProviderReasonCodes::ProviderPermissionDenied, + ProviderReasonCodes::IntuneRbacPermissionMissing => ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING, + ProviderReasonCodes::ProviderConnectionMissing, + ProviderReasonCodes::ProviderConnectionInvalid, + ProviderReasonCodes::ProviderCredentialMissing, + ProviderReasonCodes::ProviderCredentialInvalid, + ProviderReasonCodes::ProviderConnectionTypeInvalid, + ProviderReasonCodes::DedicatedCredentialMissing, + ProviderReasonCodes::DedicatedCredentialInvalid, + ProviderReasonCodes::ProviderAuthFailed, + ProviderReasonCodes::ProviderConnectionReviewRequired, + ProviderReasonCodes::ProviderBindingUnsupported, + ProviderReasonCodes::TenantTargetMismatch, + ProviderReasonCodes::PlatformIdentityMissing, + ProviderReasonCodes::PlatformIdentityIncomplete, + ProviderReasonCodes::IntuneRbacNotConfigured, + ProviderReasonCodes::IntuneRbacUnhealthy, + ProviderReasonCodes::IntuneRbacStale => ContextualHelpCatalog::CONNECTION_UNHEALTHY, + default => null, + }; + } + + private function supportDiagnosticsTopicKeyForReason(?string $reasonCode): ?string + { + if ($reasonCode === ProviderReasonCodes::ProviderPermissionRefreshFailed + || $reasonCode === ProviderReasonCodes::NetworkUnreachable + || $reasonCode === ProviderReasonCodes::RateLimited) { + return ContextualHelpCatalog::RETRYABLE_PROVIDER_FAILURE; + } + + return $this->onboardingTopicKeyForReason($reasonCode); + } + + /** + * @param list $defaultSteps + * @param array $context + * @return list + */ + private function troubleshootingSteps(array $defaultSteps, array $context): array + { + $contextSteps = $context['troubleshooting_steps'] ?? []; + + if (! is_array($contextSteps)) { + return $defaultSteps; + } + + $normalizedContextSteps = array_values(array_filter(array_map( + fn (mixed $step): ?string => $this->stringOrNull($step), + $contextSteps, + ))); + + if ($normalizedContextSteps === []) { + return $defaultSteps; + } + + return array_values(array_unique([...$defaultSteps, ...$normalizedContextSteps])); + } + + /** + * @param list $links + * @param array $context + * @return list + */ + private function resolveLinks(array $links, array $context): array + { + return array_values(array_filter(array_map( + fn (array $link): ?array => $this->resolveLink($link, $context['tenant'] ?? null), + $links, + ))); + } + + /** + * @param array{label: string, kind: string, url?: string, resolver?: string} $link + * @return array{label: string, kind: string, url: ?string, resolver: ?string}|null + */ + private function resolveLink(array $link, mixed $tenant): ?array + { + $label = $this->stringOrNull($link['label'] ?? null); + + if ($label === null) { + return null; + } + + $resolved = [ + 'label' => $label, + 'kind' => $this->stringOrNull($link['kind'] ?? null) ?? 'url', + 'url' => $this->stringOrNull($link['url'] ?? null), + 'resolver' => $this->stringOrNull($link['resolver'] ?? null), + ]; + + if (! $tenant instanceof Tenant || $resolved['resolver'] === null) { + return $resolved; + } + + $resolved['url'] = match ($resolved['resolver']) { + ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY => RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant), + ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS => RequiredPermissionsLinks::requiredPermissions($tenant), + default => $resolved['url'], + }; + + return $resolved; + } + + /** + * @param list $terms + * @return list + */ + private function canonicalTerms(array $terms): array + { + return array_values(array_unique(array_filter(array_map(function (string $term): ?string { + $canonical = $this->glossary->canonicalName($term); + + return $canonical !== null ? trim($canonical) : $this->stringOrNull($term); + }, $terms)))); + } + + private function isVerificationFailure(?string $verificationOverall, ?string $runOutcome): bool + { + return in_array($verificationOverall, ['blocked', 'needs_attention'], true) + || in_array($runOutcome, ['failed', 'blocked', 'partially_succeeded'], true); + } + + private function stringOrNull(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + $trimmed = trim($value); + + return $trimmed === '' ? null : $trimmed; + } + + private function firstNonEmpty(?string ...$values): string + { + foreach ($values as $value) { + if ($value !== null && trim($value) !== '') { + return $value; + } + } + + return ''; + } +} diff --git a/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php b/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php index 020a35cd..ade8c1b6 100644 --- a/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php +++ b/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php @@ -22,6 +22,7 @@ use App\Support\Navigation\RelatedNavigationResolver; use App\Support\OperationRunLinks; use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder; +use App\Support\ProductKnowledge\ContextualHelpResolver; use App\Support\Providers\ProviderReasonTranslator; use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary; use App\Support\RedactionIntegrity; @@ -47,6 +48,7 @@ public function __construct( private readonly GovernanceRunDiagnosticSummaryBuilder $runSummaryBuilder, private readonly ProviderReasonTranslator $providerReasonTranslator, private readonly RelatedNavigationResolver $relatedNavigationResolver, + private readonly ContextualHelpResolver $contextualHelpResolver, ) {} /** @@ -69,6 +71,7 @@ public function forTenant(Tenant $tenant, ?User $actor = null): array contextType: 'tenant', workspace: $workspace, tenant: $tenant, + providerConnection: $providerConnection, operationRun: $operationRun, headline: 'Support diagnostics for '.$tenant->name, dominantIssue: $this->tenantDominantIssue($providerConnection, $operationRun, $findings), @@ -109,6 +112,7 @@ public function forOperationRun(OperationRun $run, ?User $actor = null): array contextType: 'operation_run', workspace: $workspace, tenant: $tenant, + providerConnection: $providerConnection, operationRun: $run, headline: $runSummaryArray['headline'] ?? OperationRunLinks::identifier($run).' support diagnostics', dominantIssue: (string) data_get( @@ -137,6 +141,7 @@ private function bundle( string $contextType, ?Workspace $workspace, ?Tenant $tenant, + ?ProviderConnection $providerConnection, ?OperationRun $operationRun, string $headline, string $dominantIssue, @@ -144,6 +149,7 @@ private function bundle( ): array { $sections = $this->sortSections($sections); $redactionMarkers = RedactionIntegrity::supportDiagnosticsMarkers(); + $contextualHelp = $this->contextualHelp($tenant, $providerConnection, $operationRun, $sections); return [ 'context_type' => $contextType, @@ -173,6 +179,7 @@ private function bundle( 'redaction_note' => RedactionIntegrity::supportDiagnosticsNote(), 'generated_from' => 'derived_existing_truth', ], + 'contextual_help' => $contextualHelp, 'sections' => $sections, 'redaction' => [ 'mode' => 'default_redacted', @@ -185,6 +192,60 @@ private function bundle( ]; } + /** + * @param list> $sections + * @return array|null + */ + private function contextualHelp( + ?Tenant $tenant, + ?ProviderConnection $providerConnection, + ?OperationRun $operationRun, + array $sections, + ): ?array { + if (! $tenant instanceof Tenant) { + return null; + } + + $reasonCode = $this->supportDiagnosticReasonCode($providerConnection, $operationRun); + $topicKey = $this->contextualHelpResolver->topicKeyForSupportDiagnostics( + reasonCode: $reasonCode, + hasIncompleteEvidence: $this->completenessNote($sections) !== null, + runOutcome: $operationRun instanceof OperationRun && is_string($operationRun->outcome) + ? (string) $operationRun->outcome + : null, + ); + + if ($topicKey === null) { + return null; + } + + return $this->contextualHelpResolver->tryResolve($topicKey, [ + 'tenant' => $tenant, + 'connection' => $providerConnection, + 'reason_code' => $reasonCode, + 'surface' => 'support_diagnostics', + ]); + } + + private function supportDiagnosticReasonCode(?ProviderConnection $providerConnection, ?OperationRun $operationRun): ?string + { + $providerReasonCode = is_string($providerConnection?->last_error_reason_code) + ? trim((string) $providerConnection->last_error_reason_code) + : ''; + + if ($providerReasonCode !== '') { + return $providerReasonCode; + } + + $failureReasonCode = data_get($operationRun?->failure_summary, '0.reason_code'); + + if (is_string($failureReasonCode) && trim($failureReasonCode) !== '') { + return trim($failureReasonCode); + } + + return null; + } + private function tenantProviderConnection(Tenant $tenant): ?ProviderConnection { return ProviderConnection::query() diff --git a/apps/platform/resources/views/filament/components/product-knowledge/contextual-help.blade.php b/apps/platform/resources/views/filament/components/product-knowledge/contextual-help.blade.php new file mode 100644 index 00000000..98c0b406 --- /dev/null +++ b/apps/platform/resources/views/filament/components/product-knowledge/contextual-help.blade.php @@ -0,0 +1,80 @@ +@php + $help = is_array($help ?? null) ? $help : []; + $links = is_array($help['docs_links'] ?? null) ? $help['docs_links'] : []; + $steps = is_array($help['troubleshooting_steps'] ?? null) ? $help['troubleshooting_steps'] : []; + $headline = is_string($help['headline'] ?? null) && trim((string) ($help['headline'] ?? '')) !== '' + ? (string) ($help['headline']) + : 'Contextual help'; + $reasonLabel = is_string($help['reason_label'] ?? null) && trim((string) ($help['reason_label'] ?? '')) !== '' + ? (string) $help['reason_label'] + : null; + $showReasonLabel = $reasonLabel !== null && trim(mb_strtolower($reasonLabel)) !== trim(mb_strtolower($headline)); +@endphp + +@if ($help !== []) +
+
+
+ + Contextual help + + + @if ($showReasonLabel) + + {{ $reasonLabel }} + + @endif +
+ +
+
+ {{ $headline }} +
+
+ {{ (string) ($help['short_explanation'] ?? '') }} +
+
+ + @if (is_string($help['safe_next_action'] ?? null) && trim((string) ($help['safe_next_action'] ?? '')) !== '') + + @endif + + @if ($steps !== []) +
    + @foreach ($steps as $step) + @if (is_string($step) && trim($step) !== '') +
  • {{ $step }}
  • + @endif + @endforeach +
+ @endif + + @if ($links !== []) +
+ @foreach ($links as $link) + @php + $linkUrl = is_string($link['url'] ?? null) && trim((string) ($link['url'] ?? '')) !== '' + ? (string) $link['url'] + : null; + @endphp + + @if ($linkUrl) + + {{ (string) ($link['label'] ?? 'Open') }} + + @endif + @endforeach +
+ @endif +
+
+@endif \ No newline at end of file diff --git a/apps/platform/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php b/apps/platform/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php index 682ac055..4b8c785e 100644 --- a/apps/platform/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php +++ b/apps/platform/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php @@ -6,6 +6,7 @@ $redactionNotes = is_array($redactionNotes ?? null) ? array_values(array_filter($redactionNotes, 'is_string')) : []; + $contextualHelp = is_array($contextualHelp ?? null) ? $contextualHelp : null; $assistVisibility = is_array($assistVisibility ?? null) ? $assistVisibility : []; $assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== '' ? trim((string) $assistActionName) @@ -14,12 +15,6 @@ ? trim((string) $technicalDetailsActionName) : 'wizardVerificationTechnicalDetails'; $showAssist = (bool) ($assistVisibility['is_visible'] ?? false); - $assistReason = is_string($assistVisibility['reason'] ?? null) ? (string) $assistVisibility['reason'] : 'hidden_irrelevant'; - $assistDescription = match ($assistReason) { - 'permission_blocked' => 'Stored permission diagnostics show blockers. Review them without leaving onboarding.', - 'permission_attention' => 'Stored permission diagnostics need attention before you rerun verification.', - default => 'Review required permissions without leaving onboarding.', - }; $completedAt = is_string($run['completed_at'] ?? null) && trim((string) $run['completed_at']) !== '' ? (string) $run['completed_at'] : null; $completedAtLabel = null; @@ -52,7 +47,7 @@
@if ($runState === 'no_run') @@ -113,28 +108,8 @@
- @if ($showAssist) -
-
-
-
- Required permissions assist -
-
- {{ $assistDescription }} -
-
- - - View required permissions - -
-
+ @if ($contextualHelp !== null) + @include('filament.components.product-knowledge.contextual-help', ['help' => $contextualHelp]) @endif @include('filament.components.verification-report-viewer', [ diff --git a/apps/platform/resources/views/filament/modals/support-diagnostic-bundle.blade.php b/apps/platform/resources/views/filament/modals/support-diagnostic-bundle.blade.php index b6ec3b72..e8eec309 100644 --- a/apps/platform/resources/views/filament/modals/support-diagnostic-bundle.blade.php +++ b/apps/platform/resources/views/filament/modals/support-diagnostic-bundle.blade.php @@ -66,6 +66,10 @@ + @if (is_array($bundle['contextual_help'] ?? null)) + @include('filament.components.product-knowledge.contextual-help', ['help' => $bundle['contextual_help']]) + @endif + @if ($notes !== [])
+ diff --git a/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php b/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php index 7dcd84f7..d5c97f55 100644 --- a/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php +++ b/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php @@ -1063,7 +1063,8 @@ function createManagedReadinessBlockerDraft(string $state): array ->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()])) ->assertSuccessful() ->assertSee('Onboarding readiness') - ->assertSee('Current checkpoint') + ->assertSee('Step') + ->assertDontSee('Current checkpoint') ->assertSee('Verify access') ->assertSee('Verification has not run yet') ->assertSee('Provider connection') @@ -1165,28 +1166,46 @@ function createManagedReadinessBlockerDraft(string $state): array ->assertSee($summary) ->assertSee($nextAction); })->with([ - 'missing consent' => ['missing_consent', 'Provider consent required', 'Grant consent'], - 'revoked consent' => ['revoked_consent', 'Provider consent revoked', 'Grant consent'], + 'missing consent' => ['missing_consent', 'Provider consent required', 'Grant admin consent'], + 'revoked consent' => ['revoked_consent', 'Provider consent revoked', 'Grant admin consent'], 'disabled connection' => ['disabled_connection', 'Provider connection disabled', 'Review provider connection'], 'blocked verification' => ['blocked_verification', 'Permission or consent blocker needs attention', 'Review permissions'], ]); -it('keeps permission gap diagnostics provider-owned while top-level readiness stays neutral', function (): void { +it('keeps permission gap detail out of the top-level page once a verification report is present', function (): void { [$user, $draft, , , $missingKey] = createManagedReadinessBlockerDraft('permission_gap'); $response = $this->actingAs($user) ->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()])) ->assertSuccessful() ->assertSee('Permission or consent blocker needs attention') - ->assertSee('Permission diagnostics') - ->assertSee('Missing application permissions') + ->assertDontSee('Permission diagnostics') + ->assertSee('Supporting evidence') + ->assertSee('View required permissions') ->assertSee('Review permissions'); + if (is_string($missingKey) && $missingKey !== '') { + $response->assertDontSee($missingKey); + } + + $response->assertDontSee('Microsoft Graph readiness'); +}); + +it('shows permission diagnostics as a fallback when no verification report is present', function (): void { + [$user, $draft] = createManagedReadinessBlockerDraft('missing_consent'); + + $tenant = $draft->tenant()->firstOrFail(); + $missingKey = seedManagedReadinessPermissions($tenant); + + $response = $this->actingAs($user) + ->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()])) + ->assertSuccessful() + ->assertSee('Permission diagnostics') + ->assertDontSee('Supporting evidence'); + if (is_string($missingKey) && $missingKey !== '') { $response->assertSee($missingKey); } - - $response->assertDontSee('Microsoft Graph readiness'); }); it('downgrades route-bound readiness when permission evidence is stale and keeps the operation link canonical', function (): void { diff --git a/apps/platform/tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php b/apps/platform/tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php new file mode 100644 index 00000000..aa06b1a0 --- /dev/null +++ b/apps/platform/tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php @@ -0,0 +1,265 @@ +onboarding()->create(); + + [$user, $tenant] = createUserWithTenant( + tenant: $tenant, + role: $tenantRole, + workspaceRole: $workspaceRole, + ensureDefaultMicrosoftProviderConnection: false, + ); + + $workspace = $tenant->workspace()->firstOrFail(); + + $verificationConnection = ProviderConnection::factory()->platform()->consentGranted()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'display_name' => 'Verification connection', + 'is_default' => true, + 'consent_status' => 'granted', + ]); + + $selectedConnection = $verificationConnection; + $checks = []; + $outcome = OperationRunOutcome::Blocked->value; + + if ($state === 'admin_consent') { + $checks[] = [ + 'key' => 'permissions.admin_consent', + 'title' => 'Admin consent', + 'status' => 'fail', + 'severity' => 'critical', + 'blocking' => true, + 'reason_code' => ProviderReasonCodes::ProviderConsentMissing, + 'message' => 'Admin consent is required before verification can proceed.', + 'evidence' => [], + 'next_steps' => [], + ]; + } elseif ($state === 'required_permissions') { + $checks[] = [ + 'key' => 'permissions.required', + 'title' => 'Required application permissions', + 'status' => 'fail', + 'severity' => 'critical', + 'blocking' => true, + 'reason_code' => ProviderReasonCodes::ProviderPermissionMissing, + 'message' => 'Missing required application permissions.', + 'evidence' => [], + 'next_steps' => [], + ]; + } elseif ($state === 'connection_unhealthy') { + $checks[] = [ + 'key' => 'provider.connection.check', + 'title' => 'Provider connection check', + 'status' => 'fail', + 'severity' => 'critical', + 'blocking' => true, + 'reason_code' => ProviderReasonCodes::ProviderAuthFailed, + 'message' => 'Stored provider credentials are no longer valid.', + 'evidence' => [], + 'next_steps' => [], + ]; + } elseif ($state === 'verification_stale') { + $selectedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'dummy', + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'display_name' => 'Currently selected connection', + 'is_default' => false, + 'consent_status' => 'granted', + ]); + + $checks[] = [ + 'key' => 'provider.connection.check', + 'title' => 'Provider connection check', + 'status' => 'pass', + 'severity' => 'info', + 'blocking' => false, + 'reason_code' => 'ok', + 'message' => 'Connection is healthy.', + 'evidence' => [], + 'next_steps' => [], + ]; + + $outcome = OperationRunOutcome::Succeeded->value; + } elseif ($state === 'verification_failed') { + $checks[] = [ + 'key' => 'provider.connection.check', + 'title' => 'Provider connection check', + 'status' => 'fail', + 'severity' => 'critical', + 'blocking' => true, + 'reason_code' => '', + 'message' => 'Verification failed after the prerequisite checks ran.', + 'evidence' => [], + 'next_steps' => [], + ]; + + $outcome = OperationRunOutcome::Failed->value; + } + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => $outcome, + 'context' => [ + 'provider_connection_id' => (int) $verificationConnection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'entra_tenant_name' => (string) $tenant->name, + ], + 'verification_report' => VerificationReportWriter::build('provider.connection.check', $checks), + ], + ]); + + $draft = createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $tenant, + 'started_by' => $user, + 'updated_by' => $user, + 'current_step' => 'verify', + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'state' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'tenant_name' => (string) $tenant->name, + 'provider_connection_id' => (int) $selectedConnection->getKey(), + 'verification_operation_run_id' => (int) $run->getKey(), + ], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + return [$user, $tenant, $draft]; +} + +it('renders onboarding contextual help for each in-scope verification topic', function ( + string $state, + string $headline, + string $safeNextAction, + ?string $linkLabel, +): void { + [$user, , $draft] = createProductKnowledgeOnboardingDraft($state); + + $response = $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $user->last_workspace_id]) + ->followingRedirects() + ->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])); + + $response->assertSuccessful() + ->assertSee('Verification report') + ->assertSee('Stored verification details') + ->assertSee($headline) + ->assertDontSee('Permission diagnostics') + ->assertSee($safeNextAction); + + $dom = new \DOMDocument(); + @$dom->loadHTML($response->getContent()); + + $xpath = new \DOMXPath($dom); + + $headlineNodes = $xpath->query(sprintf( + '//*[@data-testid="contextual-help-block"]//*[normalize-space(text())="%s"]', + $headline, + )); + + $storedVerificationDetailsHeadings = $xpath->query( + '//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6][normalize-space(text())="Stored verification details"]', + ); + + $verificationReportHeadings = $xpath->query( + '//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6][normalize-space(text())="Verification report"]', + ); + + expect($headlineNodes?->length)->toBe(1); + expect($storedVerificationDetailsHeadings?->length)->toBe(1); + expect($verificationReportHeadings?->length)->toBeLessThanOrEqual(1); + + if ($state === 'admin_consent') { + $primaryNextActionNode = $xpath->query( + '//*[normalize-space(text())="Primary next action"]/following::*[(self::a or self::button) and normalize-space(text())!=""][1]', + ); + + expect(trim((string) $primaryNextActionNode?->item(0)?->textContent))->toContain('Grant admin consent'); + } + + if ($linkLabel !== null) { + $response->assertSee($linkLabel); + } +})->with([ + 'admin consent required' => [ + 'admin_consent', + 'Admin consent required', + 'Grant admin consent and re-run verification.', + 'Grant admin consent', + ], + 'required permissions missing' => [ + 'required_permissions', + 'Required permissions missing', + 'Open required permissions and confirm the missing grants.', + 'Open required permissions', + ], + 'connection unhealthy' => [ + 'connection_unhealthy', + 'Provider connection needs review', + 'Review the provider connection before retrying.', + null, + ], + 'verification stale' => [ + 'verification_stale', + 'Verification result is stale', + 'Refresh verification before continuing onboarding.', + null, + ], + 'verification failed' => [ + 'verification_failed', + 'Verification failed', + 'Review the blocking reason and retry verification.', + null, + ], +]); + +it('keeps onboarding contextual help deny-as-not-found for workspace members outside the tenant scope', function (): void { + [$authorizedUser, $tenant, $draft] = createProductKnowledgeOnboardingDraft('admin_consent'); + + $workspace = $tenant->workspace()->firstOrFail(); + $outOfScopeUser = User::factory()->create(); + + WorkspaceMembership::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $outOfScopeUser->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($outOfScopeUser) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])) + ->assertNotFound(); +}); diff --git a/apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php b/apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php new file mode 100644 index 00000000..6eea034d --- /dev/null +++ b/apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php @@ -0,0 +1,133 @@ +actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + setTenantPanelContext($tenant); + + return Livewire::actingAs($user)->test(TenantDashboard::class); +} + +function productKnowledgeSupportDiagnosticsOperationAuthorizationComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable +{ + test()->actingAs($user); + Filament::setTenant(null, true); + session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id); + + return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]); +} + +it('keeps tenant support diagnostics contextual help deny-as-not-found for workspace members without tenant entitlement', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'role' => 'operator', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)) + ->assertNotFound(); +}); + +it('returns forbidden for entitled run viewers without support diagnostics capability when requesting the contextual-help bundle', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + ]); + + productKnowledgeSupportDiagnosticsOperationAuthorizationComponent($user, $run) + ->assertActionVisible('openSupportDiagnostics') + ->assertActionDisabled('openSupportDiagnostics') + ->call('operationRunSupportDiagnosticBundle') + ->assertForbidden(); +}); + +it('omits support diagnostics contextual help when the dominant issue does not map to a catalog topic', function (): void { + $tenant = Tenant::factory()->create(['name' => 'Fallback Support Tenant']); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + $connection = ProviderConnection::factory() + ->withCredential() + ->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'display_name' => 'Fallback connection', + 'last_error_reason_code' => 'ext.support.manual_lookup_needed', + 'last_health_check_at' => now()->subMinutes(5), + ]); + + $run = OperationRun::factory() + ->forTenant($tenant) + ->create([ + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + 'completed_at' => now()->subMinutes(3), + ]); + + productKnowledgeSupportDiagnosticsOperationAuthorizationComponent($user, $run) + ->mountAction('openSupportDiagnostics') + ->assertMountedActionModalDontSee('Contextual help') + ->assertMountedActionModalDontSee('ext.support.manual_lookup_needed'); +}); + +it('keeps operation-run support diagnostics deny-as-not-found for workspace members without tenant entitlement', function (): void { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $workspace->getKey(), + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->assertNotFound(); +}); diff --git a/apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php b/apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php new file mode 100644 index 00000000..902d9a30 --- /dev/null +++ b/apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php @@ -0,0 +1,162 @@ +actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + setTenantPanelContext($tenant); + + return Livewire::actingAs($user)->test(TenantDashboard::class); +} + +function productKnowledgeOperationSupportDiagnosticsComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable +{ + test()->actingAs($user); + Filament::setTenant(null, true); + session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id); + + return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]); +} + +/** + * @return array{0: User, 1: Tenant, 2: OperationRun} + */ +function createProductKnowledgeSupportDiagnosticScenario(string $state): array +{ + $tenant = Tenant::factory()->create(['name' => 'Contoso Support Tenant']); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + $reasonCode = match ($state) { + 'admin_consent' => ProviderReasonCodes::ProviderConsentMissing, + 'required_permissions' => ProviderReasonCodes::ProviderPermissionMissing, + 'connection_unhealthy' => ProviderReasonCodes::ProviderAuthFailed, + 'retryable_provider_failure' => ProviderReasonCodes::RateLimited, + 'manual_handoff_required' => ProviderReasonCodes::UnknownError, + default => null, + }; + + $connection = $reasonCode !== null + ? ProviderConnection::factory()->withCredential()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'display_name' => 'Contoso Microsoft connection', + 'verification_status' => $reasonCode === ProviderReasonCodes::UnknownError + ? ProviderVerificationStatus::Blocked->value + : ProviderVerificationStatus::Healthy->value, + 'last_error_reason_code' => $reasonCode, + 'last_health_check_at' => now()->subMinutes(15), + ]) + : null; + + $runOutcome = match ($state) { + 'verification_failed', 'manual_handoff_required' => OperationRunOutcome::Failed->value, + default => OperationRunOutcome::Succeeded->value, + }; + + $failureSummary = match ($state) { + 'verification_failed' => [[ + 'message' => 'The operation failed and needs follow-up.', + ]], + 'manual_handoff_required' => [[ + 'message' => 'A human support handoff is required for the next step.', + 'reason_code' => ProviderReasonCodes::UnknownError, + ]], + default => [], + }; + + $context = []; + + if ($connection instanceof ProviderConnection) { + $context['provider_connection_id'] = (int) $connection->getKey(); + } + + $run = OperationRun::factory() + ->forTenant($tenant) + ->create([ + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => $runOutcome, + 'summary_counts' => [ + 'total' => 0, + 'processed' => 0, + ], + 'context' => $context, + 'failure_summary' => $failureSummary, + 'completed_at' => now()->subMinutes(10), + ]); + + return [$user, $tenant, $run]; +} + +it('renders shared product knowledge in tenant support diagnostics', function ( + string $state, + string $headline, + string $safeNextAction, + ?string $linkLabel, +): void { + [$user, $tenant] = createProductKnowledgeSupportDiagnosticScenario($state); + + $component = productKnowledgeTenantSupportDiagnosticsComponent($user, $tenant); + + $component->mountAction('openSupportDiagnostics') + ->assertMountedActionModalSee('Contextual help') + ->assertMountedActionModalSee($headline) + ->assertMountedActionModalSee($safeNextAction); + + if ($linkLabel !== null) { + $component->assertMountedActionModalSee($linkLabel); + } +})->with([ + 'tenant admin consent required' => ['admin_consent', 'Admin consent required', 'Grant admin consent and re-run verification.', 'Grant admin consent'], + 'tenant required permissions missing' => ['required_permissions', 'Required permissions missing', 'Open required permissions and confirm the missing grants.', 'Open required permissions'], + 'tenant connection unhealthy' => ['connection_unhealthy', 'Provider connection needs review', 'Review the provider connection before retrying.', null], + 'tenant verification failed' => ['verification_failed', 'Verification failed', 'Review the blocking reason and retry verification.', null], + 'tenant diagnostic evidence incomplete' => ['diagnostic_evidence_incomplete', 'Diagnostic evidence is incomplete', 'Collect a fresher or more complete diagnostic signal.', null], + 'tenant retryable provider failure' => ['retryable_provider_failure', 'Provider failure looks retryable', 'Retry after the provider dependency recovers.', null], + 'tenant manual handoff required' => ['manual_handoff_required', 'Manual support handoff required', 'Hand off the case with the current diagnostic summary and supporting references.', null], +]); + +it('renders shared product knowledge in operation support diagnostics', function ( + string $state, + string $headline, + string $safeNextAction, + ?string $linkLabel, +): void { + [$user, , $run] = createProductKnowledgeSupportDiagnosticScenario($state); + + $component = productKnowledgeOperationSupportDiagnosticsComponent($user, $run); + + $component->mountAction('openSupportDiagnostics') + ->assertMountedActionModalSee('Contextual help') + ->assertMountedActionModalSee($headline) + ->assertMountedActionModalSee($safeNextAction); + + if ($linkLabel !== null) { + $component->assertMountedActionModalSee($linkLabel); + } +})->with([ + 'operation admin consent required' => ['admin_consent', 'Admin consent required', 'Grant admin consent and re-run verification.', 'Grant admin consent'], + 'operation required permissions missing' => ['required_permissions', 'Required permissions missing', 'Open required permissions and confirm the missing grants.', 'Open required permissions'], + 'operation connection unhealthy' => ['connection_unhealthy', 'Provider connection needs review', 'Review the provider connection before retrying.', null], + 'operation verification failed' => ['verification_failed', 'Verification failed', 'Review the blocking reason and retry verification.', null], + 'operation diagnostic evidence incomplete' => ['diagnostic_evidence_incomplete', 'Diagnostic evidence is incomplete', 'Collect a fresher or more complete diagnostic signal.', null], + 'operation retryable provider failure' => ['retryable_provider_failure', 'Provider failure looks retryable', 'Retry after the provider dependency recovers.', null], + 'operation manual handoff required' => ['manual_handoff_required', 'Manual support handoff required', 'Hand off the case with the current diagnostic summary and supporting references.', null], +]); diff --git a/apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php b/apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php new file mode 100644 index 00000000..7acc6fd8 --- /dev/null +++ b/apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php @@ -0,0 +1,85 @@ +keys())->toBe([ + ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED, + ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING, + ContextualHelpCatalog::CONNECTION_UNHEALTHY, + ContextualHelpCatalog::VERIFICATION_STALE, + ContextualHelpCatalog::VERIFICATION_FAILED, + ContextualHelpCatalog::DIAGNOSTIC_EVIDENCE_INCOMPLETE, + ContextualHelpCatalog::RETRYABLE_PROVIDER_FAILURE, + ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED, + ])->and($catalog->definition(ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED))->toMatchArray([ + 'topic_key' => ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED, + 'surface_families' => ['onboarding', 'support_diagnostics'], + 'headline' => 'Admin consent required', + 'safe_next_action' => 'Grant admin consent and re-run verification.', + ]); + + $knowledgeSource = $catalog->knowledgeSource(); + $topicsByKey = collect($knowledgeSource['topics'])->keyBy('topic_key'); + + expect($knowledgeSource)->toMatchArray([ + 'version' => 1, + 'topic_count' => 8, + ])->and($topicsByKey->keys()->all())->toBe($catalog->keys()) + ->and($topicsByKey->get(ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED))->toMatchArray([ + 'headline' => 'Admin consent required', + 'docs_links' => [ + [ + 'label' => 'Grant admin consent', + 'kind' => 'action', + 'url' => null, + 'resolver' => ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY, + ], + [ + 'label' => 'Admin consent guide', + 'kind' => 'docs', + 'url' => RequiredPermissionsLinks::adminConsentGuideUrl(), + 'resolver' => null, + ], + ], + ]); +}); + +it('rejects unknown contextual help topics', function (): void { + $catalog = new ContextualHelpCatalog(); + + expect(fn (): array => $catalog->definition('unknown-topic')) + ->toThrow(InvalidArgumentException::class, 'Unknown contextual help topic'); +}); + +it('keeps every machine-readable topic on the approved metadata surface only', function (): void { + $knowledgeSource = app(\App\Support\ProductKnowledge\ContextualHelpResolver::class)->knowledgeSource(); + $allowedResolvers = [ + null, + ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY, + ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS, + ]; + + foreach ($knowledgeSource['topics'] as $topic) { + expect(array_keys($topic))->toBe([ + 'topic_key', + 'surface_families', + 'headline', + 'short_explanation', + 'troubleshooting_steps', + 'safe_next_action', + 'glossary_terms', + 'docs_links', + ]); + + foreach ($topic['docs_links'] as $link) { + expect(array_keys($link))->toBe(['label', 'kind', 'url', 'resolver']) + ->and($link['resolver'])->toBeIn($allowedResolvers); + } + } +}); diff --git a/apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php b/apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php new file mode 100644 index 00000000..a7a52b02 --- /dev/null +++ b/apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php @@ -0,0 +1,51 @@ +tryResolve(null))->toBeNull() + ->and($resolver->tryResolve(''))->toBeNull() + ->and($resolver->tryResolve('unknown-topic'))->toBeNull(); +}); + +it('keeps dynamic link metadata but no tenant-specific url when tenant context is unavailable', function (): void { + $payload = app(ContextualHelpResolver::class)->resolve(ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING); + + expect($payload['docs_links'][0])->toMatchArray([ + 'label' => 'Open required permissions', + 'resolver' => ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS, + 'url' => null, + ]); +}); + +it('exposes a safe machine-readable knowledge source without tenant or secret fields', function (): void { + $knowledgeSource = app(ContextualHelpResolver::class)->knowledgeSource(); + $encoded = json_encode($knowledgeSource, JSON_THROW_ON_ERROR); + + expect($encoded)->not->toContain('tenant_id') + ->not->toContain('provider_connection_id') + ->not->toContain('raw_response_body') + ->not->toContain('credential') + ->and($knowledgeSource['topic_count'])->toBe(8); +}); + +it('keeps explicit unmapped support-diagnostics reason codes on the null fallback path', function (): void { + $resolver = app(ContextualHelpResolver::class); + + expect($resolver->topicKeyForSupportDiagnostics( + reasonCode: 'ext.support.manual_lookup_needed', + hasIncompleteEvidence: true, + runOutcome: null, + ))->toBeNull() + ->and($resolver->topicKeyForSupportDiagnostics( + reasonCode: ProviderReasonCodes::UnknownError, + hasIncompleteEvidence: true, + runOutcome: null, + ))->toBe(ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED); +}); diff --git a/apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php b/apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php new file mode 100644 index 00000000..35b6e6d4 --- /dev/null +++ b/apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php @@ -0,0 +1,103 @@ +workspace_id, + tenantId: (int) $tenant->getKey(), + executionOutcome: 'blocked', + artifactExistence: 'created', + contentState: 'partial', + freshnessState: 'current', + publicationReadiness: null, + supportState: 'supported', + actionability: 'required', + primaryLabel: 'Verification blocked', + primaryExplanation: 'Verification cannot continue until the prerequisite is resolved.', + diagnosticLabel: 'Admin consent required', + nextActionLabel: 'Retry verification', + nextActionUrl: null, + relatedRunId: null, + relatedArtifactUrl: null, + ); +} + +it('resolves contextual help with reason translation, operator summary, and tenant-aware links', function (): void { + [, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false); + + $payload = app(ContextualHelpResolver::class)->resolve(ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED, [ + 'tenant' => $tenant, + 'reason_code' => ProviderReasonCodes::ProviderConsentMissing, + 'artifact_truth' => contextualHelpTruthEnvelope($tenant), + ]); + + expect($payload)->toMatchArray([ + 'topic_key' => ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED, + 'headline' => 'Admin consent required', + 'short_explanation' => 'The provider connection cannot continue until admin consent is granted.', + 'safe_next_action' => 'Retry verification', + 'reason_label' => 'Admin consent required', + 'diagnostic_code' => ProviderReasonCodes::ProviderConsentMissing, + ])->and($payload['docs_links'][0])->toMatchArray([ + 'label' => 'Grant admin consent', + 'resolver' => ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY, + 'url' => RequiredPermissionsLinks::adminConsentGuideUrl(), + ])->and($payload['operator_summary'])->toMatchArray([ + 'nextActionText' => 'Retry verification', + 'diagnosticsAvailable' => true, + ]); +}); + +it('resolves required permissions links against the current tenant', function (): void { + [, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false); + + $payload = app(ContextualHelpResolver::class)->resolve(ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING, [ + 'tenant' => $tenant, + ]); + + expect($payload['docs_links'][0])->toMatchArray([ + 'label' => 'Open required permissions', + 'resolver' => ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS, + 'url' => RequiredPermissionsLinks::requiredPermissions($tenant), + ]); +}); + +it('maps support diagnostics topics from shared reason and outcome signals', function (): void { + $resolver = app(ContextualHelpResolver::class); + + expect($resolver->topicKeyForSupportDiagnostics( + reasonCode: ProviderReasonCodes::RateLimited, + hasIncompleteEvidence: false, + runOutcome: null, + ))->toBe(ContextualHelpCatalog::RETRYABLE_PROVIDER_FAILURE) + ->and($resolver->topicKeyForSupportDiagnostics( + reasonCode: ProviderReasonCodes::UnknownError, + hasIncompleteEvidence: false, + runOutcome: OperationRunOutcome::Failed->value, + ))->toBe(ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED) + ->and($resolver->topicKeyForSupportDiagnostics( + reasonCode: null, + hasIncompleteEvidence: false, + runOutcome: OperationRunOutcome::Failed->value, + ))->toBe(ContextualHelpCatalog::VERIFICATION_FAILED) + ->and($resolver->topicKeyForSupportDiagnostics( + reasonCode: null, + hasIncompleteEvidence: true, + runOutcome: null, + ))->toBe(ContextualHelpCatalog::DIAGNOSTIC_EVIDENCE_INCOMPLETE); +}); diff --git a/specs/244-product-knowledge-contextual-help/checklists/requirements.md b/specs/244-product-knowledge-contextual-help/checklists/requirements.md new file mode 100644 index 00000000..245f2563 --- /dev/null +++ b/specs/244-product-knowledge-contextual-help/checklists/requirements.md @@ -0,0 +1,42 @@ +# Specification Quality Checklist: Product Knowledge & Contextual Help + +**Purpose**: Validate specification completeness and quality before proceeding to implementation planning +**Created**: 2026-04-26 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] Business value and operator outcomes stay explicit +- [x] Implementation anchors are intentional and bounded to existing repo surfaces +- [x] Runtime-governance sections are present for an implementation-ready spec package +- [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] Acceptance scenarios are defined for the primary user journeys +- [x] Edge cases are identified +- [x] Scope is clearly bounded to onboarding and support-diagnostic surface families plus one internal machine-readable knowledge source deliverable +- [x] Dependencies and assumptions are identified + +## Feature Readiness + +- [x] The first slice is small enough for a bounded implementation loop +- [x] The plan identifies the concrete repo surfaces likely to change +- [x] The tasks are ordered, testable, and grouped by user story +- [x] No unresolved product question blocks safe implementation of the first slice + +## Governance Readiness + +- [x] No new persistence is introduced without justification +- [x] Provider-boundary handling and glossary reuse are explicit +- [x] Existing RBAC and tenant/workspace isolation remain authoritative +- [x] Operator-facing surface changes include the required UI contract sections +- [x] Livewire v4 compliance, unchanged provider registration location, no global-search changes, no destructive-action additions, and no asset-strategy changes are explicit in the package + +## Notes + +- This checklist completes the implementation-ready package alongside `spec.md`, `plan.md`, and `tasks.md`. +- The active slice stays bounded to one code-owned help catalog, one resolver, two adopted surface families, and one safe machine-readable knowledge source. \ No newline at end of file diff --git a/specs/244-product-knowledge-contextual-help/plan.md b/specs/244-product-knowledge-contextual-help/plan.md new file mode 100644 index 00000000..164e2630 --- /dev/null +++ b/specs/244-product-knowledge-contextual-help/plan.md @@ -0,0 +1,199 @@ +# Implementation Plan: Product Knowledge & Contextual Help + +**Branch**: `244-product-knowledge-contextual-help` | **Date**: 2026-04-26 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +- Add a bounded `ProductKnowledge` support namespace with one code-owned contextual-help catalog and one resolver that derive help payloads from existing glossary, reason-translation, operator-explanation, and docs-link helpers. +- Adopt that resolver on two existing high-value surfaces only: `ManagedTenantOnboardingWizard` and the support-diagnostic bundle used by tenant and operation-context previews. +- Expose the same catalog as a safe machine-readable knowledge source for later internal AI/support use, while keeping the slice read-only, non-persistent, Livewire v4-compatible, and free of panel/provider/global-search/asset changes. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `PlatformVocabularyGlossary`, `ReasonPresenter`, `OperatorExplanationBuilder`, `SupportDiagnosticBundleBuilder`, `RequiredPermissionsLinks`, `ProviderReasonTranslator`, and `ManagedTenantOnboardingWizard` +**Storage**: N/A - no new database or persisted product-knowledge truth +**Testing**: Pest unit + feature tests only +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Sail-backed Laravel admin panel under `/admin` and existing support-diagnostic previews in tenant and operation contexts +**Project Type**: web +**Performance Goals**: in-memory topic lookup only, no new remote calls during render, and no extra persistence or background work for the first slice +**Constraints**: no new database table, no public docs site, no chatbot, no localization overhaul, no new global-search resource, no panel provider changes, no new Filament assets, and no direct feature-level AI execution +**Scale/Scope**: 8 canonical first-slice help topics across onboarding and support diagnostics, 1 code-owned catalog, 1 resolver, 1 machine-readable knowledge source, and focused adoption on 2 existing operator surface families + +## First-Slice Topic Inventory + +The implementation is locked to these eight canonical topic keys for the first slice: + +- `admin-consent-required` +- `required-permissions-missing` +- `connection-unhealthy` +- `verification-stale` +- `verification-failed` +- `diagnostic-evidence-incomplete` +- `retryable-provider-failure` +- `manual-handoff-required` + +Any change to this topic inventory requires an explicit spec update before implementation expands or swaps the slice. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament + shared diagnostics data +- **Shared-family relevance**: status messaging, docs links, troubleshooting guidance, support-diagnostic summaries +- **State layers in scope**: page, workflow step, detail reveal, action preview, diagnostic section detail +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: standard-native-filament, monitoring-state-page +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder`, `App\Support\Governance\PlatformVocabularyGlossary`, `App\Support\ReasonTranslation\ReasonPresenter`, `App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder`, `App\Support\Providers\ProviderReasonTranslator`, and `App\Support\Links\RequiredPermissionsLinks` +- **Shared abstractions reused**: glossary classification, reason envelopes, operator-explanation patterns, support-diagnostic section assembly, and existing provider docs-link helpers +- **New abstraction introduced? why?**: one bounded contextual-help catalog plus one resolver are justified because the repo has truthful status and glossary primitives already, but it has no shared product-knowledge layer with stable topic keys, troubleshooting guidance, or machine-readable knowledge source +- **Why the existing abstraction was sufficient or insufficient**: existing abstractions explain current state, but not reusable contextual help. They are sufficient as inputs and insufficient as the final cross-surface help contract. +- **Bounded deviation / spread control**: no page-local help registries, no second glossary, and no product-knowledge persistence. Provider-specific remediation remains bounded to provider-owned topic entries and existing link helpers only. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no +- **Central contract reused**: N/A +- **Delegated UX behaviors**: N/A +- **Surface-owned behavior kept local**: N/A +- **Queued DB-notification policy**: N/A +- **Terminal notification path**: N/A +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes +- **Provider-owned seams**: `RequiredPermissionsLinks`, provider-specific consent/permission guidance, and `ProviderReasonTranslator`-backed help topics +- **Platform-core seams**: contextual-help topic IDs, glossary mapping, onboarding help rendering, support-diagnostic help rendering, and the machine-readable catalog export +- **Neutral platform terms / contracts preserved**: contextual help, help topic, troubleshooting guidance, next action, support diagnostics, readiness guidance +- **Retained provider-specific semantics and why**: Microsoft admin consent and required-permissions steps remain provider-specific because those remediation paths are concrete current-release truth rather than speculative portability work +- **Bounded extraction or follow-up path**: `document-in-feature` for future localization compatibility; no broader follow-up is required for the first slice + +## Constitution Check + +*GATE: Must pass before implementation begins. Re-check after design changes.* + +- Inventory-first / snapshots-second: PASS - contextual help is derived from existing truth only and does not become a new system-of-record +- Read/write separation: PASS - the slice is read-only guidance only and introduces no new mutation flow +- Graph contract path: PASS - the feature adds no new Microsoft Graph calls +- RBAC-UX / workspace isolation / tenant isolation: PASS - existing onboarding, tenant, and operation-view entitlements stay authoritative and contextual help resolves only after host-surface authorization succeeds +- Shared pattern reuse / `XCUT-001`: PASS - the design explicitly extends glossary, reason, operator-explanation, support-diagnostic, and existing link helpers instead of adding local help prose paths +- Proportionality / `PROP-001` and `ABSTR-001`: PASS - one bounded catalog and one resolver are the narrowest reusable shape that avoids page-local drift +- Persisted truth / `PERSIST-001`: PASS - no new persistence is introduced +- UI semantics / `UI-SEM-001`: PASS - the feature adds progressive disclosure help only and does not replace the host surface's truth model +- Filament-native UI / `UI-FIL-001`: PASS - onboarding and preview hosts remain native Filament/shared surfaces; no bespoke status cards or asset changes are planned +- Livewire v4 / Filament v5: PASS - the feature remains fully within the existing Filament v5 + Livewire v4 stack and requires no provider registration changes beyond the current `bootstrap/providers.php` location +- Global search rule: N/A - no new resource or global-search configuration is introduced +- Destructive actions: PASS - no new destructive action is introduced; existing confirmations remain unchanged +- Asset strategy: PASS - no new global or on-demand assets are planned, so `filament:assets` deployment behavior is unchanged +- Test governance / `TEST-GOV-001`: PASS - proof remains in focused unit + feature tests only + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for catalog shape, resolver behavior, and fallback/export safety; Feature for onboarding help rendering, support-diagnostic help rendering, and authorization-safe degradation +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: the feature is server-driven and view-model oriented; browser automation would duplicate what focused unit and feature tests can already prove +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php` +- **Fixture / helper / factory / seed / context cost risks**: reuse existing workspace, onboarding draft, tenant, provider connection, operation run, and support-diagnostic fixtures; avoid new browser or provider-emulator defaults +- **Expensive defaults or shared helper growth introduced?**: no +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: standard-native-filament relief for onboarding plus one monitoring-state-page regression for the operation-context support-diagnostic host +- **Closing validation and reviewer handoff**: reviewers should verify registry-backed help only, progressive disclosure, glossary alignment, authorization-safe link resolution, graceful fallback on missing topics, and zero panel/provider/asset/global-search drift +- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep +- **Review-stop questions**: did the implementation add page-local help prose, new persistence, or new AI execution; do missing topics fail gracefully; do help links stay entitlement-safe? +- **Escalation path**: `reject-or-split` if the implementation grows into a public docs platform, localization rewrite, or AI execution feature; otherwise changes stay inside this feature +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: the first slice is intentionally narrow and can land independently before broader localization, support, or AI work + +## Project Structure + +### Documentation (this feature) + +```text +specs/244-product-knowledge-contextual-help/ +├── checklists/ +│ └── requirements.md +├── spec.md +├── plan.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/ +│ │ │ ├── Operations/TenantlessOperationRunViewer.php +│ │ │ ├── TenantDashboard.php +│ │ │ └── Workspaces/ManagedTenantOnboardingWizard.php +│ │ └── Support/ +│ ├── Support/ +│ │ ├── Governance/PlatformVocabularyGlossary.php +│ │ ├── Links/RequiredPermissionsLinks.php +│ │ ├── ProductKnowledge/ +│ │ │ ├── ContextualHelpCatalog.php +│ │ │ └── ContextualHelpResolver.php +│ │ ├── ReasonTranslation/ReasonPresenter.php +│ │ ├── SupportDiagnostics/SupportDiagnosticBundleBuilder.php +│ │ └── Ui/OperatorExplanation/OperatorExplanationBuilder.php +│ └── Services/ +└── tests/ + ├── Unit/Support/ProductKnowledge/ + │ ├── ContextualHelpCatalogTest.php + │ ├── ContextualHelpResolverTest.php + │ └── ContextualHelpFallbackTest.php + └── Feature/ + ├── Onboarding/ProductKnowledgeOnboardingHelpTest.php + └── SupportDiagnostics/ + ├── ProductKnowledgeAuthorizationTest.php + └── ProductKnowledgeSupportDiagnosticHelpTest.php +``` + +**Structure Decision**: Single Laravel web application. The implementation adds one small support namespace and adopts it on existing onboarding and support-diagnostic surfaces only. + +## Complexity Tracking + +No constitution violations are required. The only new structure is the explicitly justified code-owned contextual-help catalog plus resolver. + +## Proportionality Review + +- **Current operator problem**: operators still need founder explanation or scattered docs to interpret onboarding blockers and support-diagnostic dominant issues safely +- **Existing structure is insufficient because**: the repo has truthful glossary, reason, and diagnostic primitives but no versioned, reusable product-knowledge layer +- **Narrowest correct implementation**: one code-owned catalog plus one resolver reused by onboarding and support diagnostics only +- **Ownership cost created**: topic keys, docs-link mappings, fallback behavior, and focused tests +- **Alternative intentionally rejected**: page-local prose, public docs platform, CMS/editor, or AI execution layer +- **Release truth**: current-release truth + +## Rollout & Risk Controls + +- Start with onboarding guidance and support diagnostics only. Any third adoption surface requires explicit scope review. +- Keep help blocks progressive and subordinate to the host surface's existing status or diagnostic truth. +- Use only approved docs-link helpers or stable URLs for the first slice. No free-text or user-authored help content is allowed. +- Keep the machine-readable knowledge source internal and code-owned. No runtime AI invocation or customer-facing knowledge export is part of this slice. + +## Implementation Outline + +- Add `App\Support\ProductKnowledge\ContextualHelpCatalog` and `ContextualHelpResolver` as the single shared path for first-slice help topics. +- Integrate onboarding help topic selection inside `ManagedTenantOnboardingWizard` using the existing readiness, permission, and verification signals already present on the page. +- Integrate contextual help into `SupportDiagnosticBundleBuilder` so tenant and operation-context previews render the same help payload from the same topic keys. +- Expose a safe machine-readable knowledge-source method from the catalog or resolver and add focused unit + feature coverage for rendering, authorization, and fallback. + +## Constitution Check (Post-Design) + +Re-check result: PASS. The plan stays bounded to one code-owned catalog and one resolver, reuses existing glossary/reason/support primitives, adds no new persistence, keeps Filament v5 / Livewire v4 unchanged, leaves provider registration in `bootstrap/providers.php` untouched, introduces no global-search or asset changes, and keeps proof in narrow unit + feature coverage only. + diff --git a/specs/244-product-knowledge-contextual-help/spec.md b/specs/244-product-knowledge-contextual-help/spec.md new file mode 100644 index 00000000..9777521a --- /dev/null +++ b/specs/244-product-knowledge-contextual-help/spec.md @@ -0,0 +1,283 @@ +# Feature Specification: Product Knowledge & Contextual Help + +**Feature Branch**: `244-product-knowledge-contextual-help` +**Created**: 2026-04-26 +**Status**: Ready for implementation +**Input**: User description: "Promote the roadmap-fit candidate Product Knowledge & Contextual Help as a narrow, implementation-ready slice that introduces a code-owned contextual help contract for operator-facing guidance on existing onboarding and diagnostics surfaces, reuses glossary and reason-translation foundations, and stops before AI, chatbot, or public docs platform scope." + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: Operators still need founder explanation when onboarding blockers, support-diagnostic summaries, or reason-translated states explain what happened but not the safest next step, the relevant documentation, or the surrounding product meaning. +- **Today's failure**: Tenant onboarding and support-oriented diagnostic surfaces already expose truthful status and reason signals, but help remains scattered across local copy, existing docs knowledge, or founder memory. That slows onboarding, increases support load, and leaves later AI-assisted support without a trusted product-knowledge source. +- **User-visible improvement**: Operators see contextual help on two high-value existing surfaces with canonical terminology, troubleshooting hints, and documentation links that match the current issue without replacing the underlying truth or opening raw diagnostics first. +- **Smallest enterprise-capable version**: Add one code-owned contextual help catalog plus one resolver that reuses the existing glossary, reason-translation, operator-explanation, and required-permissions link helpers for two adoption surfaces only: the managed-tenant onboarding workflow and the support-diagnostic bundle in tenant and operation contexts. +- **Explicit non-goals**: No public docs site, no AI chatbot, no broad CMS/editor workflow, no complete localization overhaul, no customer-facing help center, no rewrite of every operator surface, and no new persisted product-knowledge table. +- **Permanent complexity imported**: One bounded `ProductKnowledge` support namespace, one catalog of stable help topic keys, one resolver/presenter path, one machine-readable source export for later AI/support use, and focused unit plus feature tests. +- **Why now**: The repo already has `PlatformVocabularyGlossary`, `ReasonPresenter`, `OperatorExplanationBuilder`, `ManagedTenantOnboardingWizard`, and `SupportDiagnosticBundleBuilder`. Product Knowledge is the smallest next slice that makes onboarding and support less founder-dependent while preparing a safe knowledge source for later AI-assisted support. +- **Why not local**: Local page copy would duplicate glossary and reason semantics, drift across onboarding and diagnostics surfaces, and fail to produce one reviewable, versioned, machine-readable product-knowledge source. +- **Approval class**: Workflow Compression +- **Red flags triggered**: New meta-infrastructure and a foundation-sounding theme. Defense: the slice stays bounded to two existing adoption surfaces, introduces no new persistence, and reuses existing glossary/reason/support primitives rather than inventing a generic knowledge platform. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace +- **Primary Routes**: + - `/admin/onboarding` + - `/admin/onboarding/{onboardingDraft}` + - existing tenant support-diagnostics entry points on `/admin/t/{tenant}` + - existing canonical operation detail support-diagnostics entry points on `/admin/operations/{run}` +- **Data Ownership**: + - No new database table or persisted product-knowledge entity is introduced. + - The contextual help catalog remains code-owned, reviewable, and versioned in the repository. + - Source truth remains on `PlatformVocabularyGlossary`, `ReasonResolutionEnvelope`, `OperatorExplanationPattern`, `SupportDiagnosticBundleBuilder`, and existing route/link helpers. + - Any machine-readable knowledge source exported by the feature is derived from the code-owned catalog and MUST exclude customer content, provider payloads, and secrets. +- **RBAC**: + - This slice introduces no new capability family. + - Existing onboarding authorization remains authoritative for `/admin/onboarding` and the managed-tenant onboarding draft flow. + - Existing support-diagnostics and operation-view entitlement checks remain authoritative for tenant and operation diagnostic entry points. + - Non-members or wrong-scope actors continue to receive 404. In-scope actors lacking the existing capability continue to receive 403. Help resolution never runs before those scope checks pass. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: N/A - this slice does not add a new canonical list or queue. It annotates existing onboarding and diagnostic detail surfaces only. +- **Explicit entitlement checks preventing cross-tenant leakage**: Contextual help is resolved only after the host surface has already resolved workspace and tenant entitlement. Help topics may reference only routes, documents, and next steps the current actor is already entitled to see. + +## 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 +- **Interaction class(es)**: status messaging, supporting docs links, troubleshooting guidance, support-diagnostic summaries, onboarding next-step guidance +- **Systems touched**: `ManagedTenantOnboardingWizard`, `SupportDiagnosticBundleBuilder`, `PlatformVocabularyGlossary`, `ReasonPresenter`, `OperatorExplanationBuilder`, `ProviderReasonTranslator`, and `RequiredPermissionsLinks` +- **Existing pattern(s) to extend**: canonical glossary terms, reason-translation envelopes, operator-explanation summaries, support-diagnostic section assembly, and existing required-permissions/admin-consent link helpers +- **Shared contract / presenter / builder / renderer to reuse**: `App\Support\Governance\PlatformVocabularyGlossary`, `App\Support\ReasonTranslation\ReasonPresenter`, `App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder`, `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder`, and `App\Support\Links\RequiredPermissionsLinks` +- **Why the existing shared path is sufficient or insufficient**: existing shared paths already provide truthful labels, diagnostic summaries, glossary boundaries, and remediation links, but they do not provide one reviewable cross-surface product-knowledge layer with stable help topic keys, progressive disclosure copy, or a machine-readable knowledge source. +- **Allowed deviation and why**: provider-specific consent and required-permissions guidance may remain inside provider-owned help topics because the concrete remediation path is still Microsoft-specific in the current release. +- **Consistency impact**: topic keys, help headings, glossary nouns, troubleshooting steps, and docs links must stay aligned across onboarding and support diagnostics so the same state does not produce competing explanations. +- **Review focus**: reviewers must block page-local contextual-help prose that bypasses the shared catalog and must confirm that help copy stays derived from glossary/reason/support truth rather than becoming a second semantic source of truth. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: no +- **Shared OperationRun UX contract/layer reused**: N/A - the slice annotates existing onboarding and support-diagnostic surfaces only and does not change how runs are started, linked, or messaged. +- **Delegated start/completion UX behaviors**: N/A +- **Local surface-owned behavior that remains**: N/A +- **Queued DB-notification policy**: N/A +- **Terminal notification path**: N/A +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: yes +- **Boundary classification**: mixed +- **Seams affected**: provider permission/consent guidance, provider reason translation reuse, glossary-backed terminology, support-diagnostic guidance, and documentation link resolution +- **Neutral platform terms preserved or introduced**: contextual help, help topic, troubleshooting guidance, next action, support diagnostics, readiness, operator guidance +- **Provider-specific semantics retained and why**: Microsoft admin consent and required-permissions guidance remain provider-owned because those remediation steps still require exact provider terminology and URLs in the current release. +- **Why this does not deepen provider coupling accidentally**: provider-specific help remains attached to provider-owned topics and existing provider link helpers. The top-level catalog, topic IDs, glossary references, and host-surface contracts remain platform-neutral. +- **Follow-up path**: `document-in-feature` for the Platform Localization v1 dependency boundary; no follow-up spec is required for the bounded first slice itself. + +## 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 | +|---|---|---|---|---|---|---| +| Managed tenant onboarding workflow | yes | Native Filament + shared primitives | status messaging, docs links, readiness guidance | page, workflow step, detail reveal | no | Adds contextual help beside existing readiness and verification signals only | +| Tenant dashboard support-diagnostic preview | yes | Native Filament action + shared diagnostics bundle | diagnostic summaries, docs links, troubleshooting guidance | action preview, diagnostic section detail | no | Help enriches the existing derived bundle instead of creating a second support surface | +| Operation detail support-diagnostic preview | yes | Native Filament detail action + shared diagnostics bundle | diagnostic summaries, docs links, troubleshooting guidance | detail, action preview, diagnostic section detail | no | Reuses the same help-resolution path as tenant diagnostics with operation-context inputs | + +## 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 | +|---|---|---|---|---|---|---|---| +| Managed tenant onboarding workflow | Primary Decision Surface | Decide what blocker to resolve next so onboarding can continue safely | current blocker meaning, one help headline, one safe next step, and one supporting docs link when relevant | full verification detail, provider-specific evidence, operation detail, and raw diagnostics | Primary because the operator is already in the guided setup workflow and needs help in that context | Follows the identify-connect-verify-complete onboarding workflow | Removes the need to switch to founder memory or separate documentation to interpret the blocker | +| Tenant dashboard support-diagnostic preview | Secondary Context Surface | Decide how to troubleshoot or escalate a tenant issue from one support-safe summary | dominant issue meaning, contextual help headline, troubleshooting hints, and safe next step | full bundle sections, related records, and diagnostic evidence | Secondary because support diagnostics remain a follow-up to tenant work, not the primary workflow | Follows tenant troubleshooting and escalation flow | Reduces cross-page reconstruction and repeated explanation work | +| Operation detail support-diagnostic preview | Tertiary Evidence / Diagnostics Surface | Decide what the current run outcome means before drilling deeper or escalating | run summary meaning, contextual help headline, troubleshooting hints, and safe next step | canonical run detail, related records, and provider diagnostics | Tertiary because the surface is already evidence-first and the help layer should remain progressive disclosure | Follows monitoring and support drill-in flow | Makes the existing diagnostic surface more self-explanatory without turning it into a new queue | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Managed tenant onboarding workflow | Workflow / Guided action entry | Guided onboarding / readiness workflow | Resolve the blocker or continue to the next checkpoint | In-page readiness and contextual-help section on the current draft route | forbidden | Supporting docs and diagnostics stay inside the section reveal | Existing destructive draft actions remain in the header only | `/admin/onboarding` | `/admin/onboarding/{onboardingDraft}` | Workspace context, linked tenant, provider readiness summary, help topic scope | Onboarding / Onboarding guidance | Blocker meaning, safe next step, and supporting docs link where applicable | guided-workflow exception already inherent to the onboarding wizard | +| Tenant dashboard support-diagnostic preview | Dashboard / Overview / Actions | Tenant troubleshooting support entry point | Open support diagnostics and follow the documented next troubleshooting step | Explicit support-diagnostics action opens the read-only preview | forbidden | Related record links and docs links remain inside the preview | none | `/admin/t/{tenant}` | `/admin/t/{tenant}` | Active workspace, active tenant, help topic scope, bundle freshness | Support diagnostics / Diagnostic help | Dominant issue meaning, troubleshooting guidance, and supporting docs links | dashboard-action entry point only; help remains read-only | +| Operation detail support-diagnostic preview | Record / Detail / Actions | Canonical diagnostic detail support entry point | Open support diagnostics and follow the documented next troubleshooting step | Existing operation detail plus one explicit support-diagnostics action | forbidden | Related record links and docs links remain inside the preview | none | `/admin/operations` | `/admin/operations/{run}` | Workspace context, tenant context when present, help topic scope, bundle freshness | Support diagnostics / Diagnostic help | Dominant issue meaning, troubleshooting guidance, and supporting docs links | 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Managed tenant onboarding workflow | Workspace operator managing tenant setup | Decide what the current blocker means and what action to take next | Guided workflow | What does this blocker mean, and what should I do next? | readiness headline, blocker explanation, one safe next action, one docs link where relevant, and current checkpoint context | provider-specific evidence, full verification report, operation detail, low-level identifiers | readiness, data freshness, provider health, operator actionability | Existing onboarding actions keep their current scope; the help layer itself is read-only | Continue onboarding, open docs link, open supporting diagnostics | Existing cancel/delete draft actions remain unchanged | +| Tenant dashboard support-diagnostic preview | Support-capable tenant operator or manager | Decide whether to troubleshoot, hand off, or escalate a tenant issue | Read-only preview | What does the current tenant issue mean, and which documented next step is safest? | dominant issue explanation, contextual help headline, troubleshooting steps, and docs links | full support bundle sections, related records, provider diagnostics, audit references | execution outcome, provider health, findings pressure, guidance actionability | none | Open support diagnostics, open docs link, open related records | none | +| Operation detail support-diagnostic preview | Support-capable operator | Decide whether to troubleshoot, hand off, or escalate a run-centered issue | Read-only preview | What does this run outcome mean, and which documented next step applies? | dominant issue explanation, contextual help headline, troubleshooting steps, and docs links | full support bundle sections, run detail, provider diagnostics, audit references | execution outcome, trustworthiness, guidance actionability | none | Open support diagnostics, open docs link, open related records | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: yes +- **New enum/state/reason family?**: no +- **New cross-domain UI framework/taxonomy?**: yes, one bounded contextual-help topic catalog for the first slice +- **Current operator problem**: operators still need founder explanation or external notes to interpret onboarding blockers and support-diagnostic dominant issues safely. +- **Existing structure is insufficient because**: glossary and reason translation explain current state, but they do not provide one reusable, versioned, cross-surface product-knowledge layer with troubleshooting hints, docs links, or a machine-readable knowledge source. +- **Narrowest correct implementation**: one code-owned contextual-help catalog plus one resolver for onboarding and support diagnostics only, reusing existing glossary/reason/support primitives and avoiding persistence, CMS tooling, or AI execution. +- **Ownership cost**: maintain help topic keys, docs-link mappings, glossary alignment, fallback handling, and focused unit plus feature tests. +- **Alternative intentionally rejected**: page-local help copy was rejected as drift-prone, and a full public-docs/help-center platform was rejected as broader than current-release truth. +- **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**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: unit tests prove the bounded catalog, topic resolution, glossary linkage, and fallback behavior. Feature tests prove the two adopted surfaces render contextual help only after existing authorization succeeds and do so without introducing new routes, persistence, or browser-only behavior. +- **New or expanded test families**: one focused `ProductKnowledge` unit family and targeted feature coverage for onboarding help rendering, support-diagnostic help rendering, and authorization-safe fallback behavior. +- **Fixture / helper cost impact**: low-to-moderate. Reuse existing onboarding draft, tenant, workspace, provider connection, operation run, and support-diagnostic fixtures. No new browser harness, provider emulator, or heavy-governance lane is required. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: standard-native-filament, monitoring-state-page +- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient for the onboarding wizard; the operation detail adoption also needs one monitoring-state-page regression because the contextual help is rendered from the support-diagnostic path on a monitoring-oriented surface. +- **Reviewer handoff**: reviewers must confirm that contextual help remains registry-backed, progressive, and entitlement-safe; that missing help topics fail predictably; and that help copy does not become a second source of truth or change operation/onboarding authorization semantics. +- **Budget / baseline / trend impact**: low increase in narrow unit and feature coverage only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php` + +## First-Slice Topic Inventory *(mandatory for implementation lock-in)* + +The first slice is locked to the following eight canonical help topic keys. Adding or replacing a first-slice topic requires an explicit spec update. + +| Topic Key | Intended Surface Families | Primary Trigger | Shared Truth Reused | +|---|---|---|---| +| `admin-consent-required` | onboarding, support diagnostics | provider readiness or dominant issue indicates admin consent is still required | `RequiredPermissionsLinks`, glossary terms, reason translation | +| `required-permissions-missing` | onboarding, support diagnostics | provider readiness or dominant issue indicates required permissions are missing or incomplete | `RequiredPermissionsLinks`, glossary terms, reason translation | +| `connection-unhealthy` | onboarding, support diagnostics | provider connection health is degraded or disconnected | operator explanation, reason translation, diagnostic summary | +| `verification-stale` | onboarding | verification has not been refreshed recently enough to trust readiness | onboarding verification state, glossary terms | +| `verification-failed` | onboarding, support diagnostics | verification or readiness checks completed with a failing result | operator explanation, reason translation | +| `diagnostic-evidence-incomplete` | support diagnostics | the bundle cannot prove a dominant issue with high confidence because evidence is incomplete | diagnostic bundle summary, glossary terms | +| `retryable-provider-failure` | support diagnostics | support diagnostics indicate a provider-side failure that is safe to retry or re-check | reason translation, operator explanation | +| `manual-handoff-required` | support diagnostics | the system can summarize the problem but requires a human support handoff or explicit escalation path | diagnostic bundle summary, glossary terms, approved docs links | + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Explain Onboarding Blockers In Context (Priority: P1) + +As a workspace operator, I want onboarding blockers to show contextual help with canonical terminology, safe next steps, and supporting docs links so I can continue onboarding without founder intervention. + +**Why this priority**: This is the most immediate operator-facing support reduction in the roadmap cluster and reuses the repo's existing onboarding and permission diagnostics foundations. + +**Independent Test**: Open onboarding drafts that are blocked by missing consent, missing permissions, unhealthy provider connection, or stale verification and verify that the wizard shows registry-backed contextual help without changing the existing readiness truth. + +**Acceptance Scenarios**: + +1. **Given** an authorized operator opens an onboarding draft blocked by missing admin consent, **When** the readiness step renders, **Then** the workflow shows a contextual help headline, one safe next step, and an admin-consent docs/action link derived from the shared help registry. +2. **Given** an authorized operator opens an onboarding draft blocked by missing permissions or stale verification, **When** the readiness step renders, **Then** the workflow shows glossary-aligned contextual help that explains the blocker without replacing the existing verification or provider-truth sections. +3. **Given** the current user is not entitled to the onboarding scope, **When** they attempt to access the draft, **Then** the system still returns 404 or 403 according to existing rules and reveals no contextual-help details. + +--- + +### User Story 2 - Reuse The Same Product Knowledge In Support Diagnostics (Priority: P1) + +As a support-capable operator, I want tenant and operation support-diagnostic previews to show the same contextual help language and troubleshooting guidance so support cases stop depending on ad-hoc explanation. + +**Why this priority**: The second high-value surface proves the knowledge layer is genuinely reusable and not just onboarding-local prose. + +**Independent Test**: Open tenant-context and operation-context support diagnostics for the same dominant issue and verify that the preview renders the same registry-backed help topic, troubleshooting hints, and supporting docs links. + +**Acceptance Scenarios**: + +1. **Given** a tenant support-diagnostic bundle resolves a dominant issue such as missing permissions or an unhealthy connection, **When** the preview renders, **Then** it includes registry-backed contextual help aligned with the dominant issue and leaves the existing diagnostic sections intact. +2. **Given** an operation-context support-diagnostic bundle resolves the same dominant issue, **When** the preview renders, **Then** it uses the same help topic key and glossary-aligned language rather than a second local explanation dialect. +3. **Given** the dominant issue has no configured help topic in the first slice, **When** the preview renders, **Then** the bundle degrades gracefully without exceptions or raw unresolved keys. +4. **Given** a user lacks the existing support-diagnostic entitlement for the tenant or operation scope, **When** they attempt to open the preview, **Then** the host surface preserves the current 404/403 behavior and reveals no contextual-help payload. + +--- + +### User Story 3 - Provide A Safe Machine-Readable Knowledge Source (Priority: P2) + +As the product owner, I want the first-slice help catalog to expose a machine-readable knowledge source so later AI-assisted support can reuse trusted product knowledge without scraping UI prose or customer data. + +**Why this priority**: This keeps the first slice aligned with later AI-adjacent work without forcing AI execution or broad platform scope into the current implementation. + +**Independent Test**: Resolve the first-slice catalog into a machine-readable knowledge source and verify that it contains only topic metadata, glossary-aligned text, and allowed docs links, with no tenant-specific data or secrets. + +**Acceptance Scenarios**: + +1. **Given** the code-owned help catalog, **When** the machine-readable knowledge source is exported for internal product use, **Then** it contains only stable topic keys, headings, troubleshooting steps, and allowed links. +2. **Given** a help topic references existing route or docs helpers, **When** the machine-readable knowledge source is built, **Then** the exported representation contains only safe link metadata and never includes tenant-specific provider payloads, secrets, or free-text customer notes. + +### Edge Cases + +- A host surface may resolve a reason or dominant issue that has no mapped help topic in the first slice; the UI must fail predictably and preserve the underlying truth without showing raw topic keys. +- A provider-owned topic may have both an internal product route and an external Microsoft docs link; the surfaced links must stay ordered and entitlement-safe. +- The same help topic may appear on onboarding and support diagnostics; the wording must remain stable even if the surrounding surface framing differs. +- Progressive disclosure must keep the help block subordinate to the surface's primary truth so the product does not imply the help copy is itself the source of truth. +- Localization remains out of scope for the first slice; help topics must stay ready for later localization without introducing a second vocabulary layer now. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature introduces no new Graph call path and no new tenant-changing action. It adds a read-only contextual-help layer on top of existing onboarding and support-diagnostic truths. Existing write, queue, and audit semantics remain unchanged. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The slice introduces one new bounded abstraction because current-release operator workflows now need a reusable help layer. No new persistence, new state family, or generic knowledge platform is introduced. + +**Constitution alignment (XCUT-001):** This slice is cross-cutting across onboarding and support diagnostics. It must reuse the existing glossary, reason-translation, operator-explanation, and support-diagnostic bundle paths rather than introducing page-local help dialects. + +**Constitution alignment (PROV-001):** Provider-specific remediation remains bounded to provider-owned topics and existing docs-link helpers. Platform-core help topics remain provider-neutral. + +**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit and feature lanes only. No browser or heavy-governance family is justified. + +**Constitution alignment (RBAC-UX):** Existing scope and capability checks remain authoritative. Help resolution must not widen access, leak hidden remediation destinations, or replace 404/403 semantics. + +**Constitution alignment (OPS-UX):** The feature does not create or change `OperationRun` start, completion, notification, or link semantics. + +**Constitution alignment (BADGE-001):** The feature introduces no new badge domain. If existing badge or status labels appear inside help, they must be reused from existing catalog-backed semantics. + +**Constitution alignment (UI-FIL-001):** Operator-facing help must use native Filament or shared diagnostic primitives on adopted surfaces. No ad-hoc status cards or new local status language are allowed. + +**Constitution alignment (UI-NAMING-001):** Help headlines, troubleshooting hints, docs links, and surrounding UI copy must preserve the same canonical vocabulary already used by reason translation, onboarding readiness, and support diagnostics. + +### Functional Requirements + +- **FR-244-001**: The system MUST define one code-owned contextual-help catalog for the bounded first slice. +- **FR-244-002**: The catalog MUST use stable help topic keys and remain reviewable and versioned in the repository. +- **FR-244-003**: The contextual-help resolver MUST reuse `PlatformVocabularyGlossary`, reason-translation outputs, operator-explanation outputs, and existing docs-link helpers instead of duplicating those semantics. +- **FR-244-004**: The managed-tenant onboarding workflow MUST render registry-backed contextual help for the first-slice blocker families when a matching help topic exists. +- **FR-244-005**: Tenant-context and operation-context support-diagnostic previews MUST render registry-backed contextual help for the first-slice dominant-issue families when a matching help topic exists. +- **FR-244-006**: Contextual help MUST remain progressive disclosure and MUST NOT replace the host surface's primary truth sections. +- **FR-244-007**: Each help topic MUST support a bounded shape containing a headline, short explanation, troubleshooting steps, safe next action, and zero or more supporting docs links. +- **FR-244-008**: Help copy MUST reuse canonical glossary and reason-translation vocabulary and MUST NOT invent conflicting synonyms for onboarding, diagnostics, evidence, drift, support, or operation outcomes. +- **FR-244-009**: Provider-specific help MUST remain bounded to provider-owned topics and existing provider link helpers. +- **FR-244-010**: Missing or invalid help topics MUST degrade gracefully without exceptions, broken UI state, or raw unresolved topic keys. +- **FR-244-011**: The feature MUST expose a machine-readable knowledge source safe for future internal AI/support use without tenant-specific data, provider payloads, or secrets. +- **FR-244-012**: The first slice MUST NOT introduce a public documentation site, chatbot, CMS/editor, new database table, or customer-facing help center. +- **FR-244-013**: Contextual help MUST not change existing onboarding, support-diagnostic, or authorization behavior. +- **FR-244-014**: The feature MUST include regression coverage for onboarding help rendering, support-diagnostic help rendering, and missing-topic fallback behavior. +- **FR-244-015**: The feature MUST include at least one positive and one negative authorization regression proving that contextual help never leaks hidden scope or destinations. + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Managed tenant onboarding workflow | `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` | Existing header actions remain unchanged | N/A | none added by this feature | none | existing empty/start state unchanged | N/A | existing onboarding actions unchanged | no | Adds a read-only contextual-help block only; no new destructive or mutating action | +| Tenant support-diagnostic preview host | `apps/platform/app/Filament/Pages/TenantDashboard.php` | Existing support-diagnostics action unchanged | N/A | none added by this feature | none | none | N/A | N/A | no | Help is rendered inside the preview content returned by the shared bundle builder | +| Operation detail support-diagnostic preview host | `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Existing support-diagnostics action unchanged | N/A | none added by this feature | none | none | existing run actions unchanged | N/A | no | Monitoring/detail action hierarchy remains unchanged; help annotates preview content only | + +### Key Entities *(include if feature involves data)* + +- **Contextual Help Topic**: A code-owned, versioned help entry identified by a stable topic key and containing bounded product guidance only. +- **Contextual Help Resolution**: A derived help payload built from catalog entries plus existing glossary, reason, operator-explanation, and docs-link inputs. +- **Machine-Readable Knowledge Source**: A safe export of the code-owned help catalog for future internal AI/support consumption. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-244-001**: The first implementation slice renders registry-backed contextual help on at least two critical surfaces: the managed-tenant onboarding workflow and support-diagnostic previews. +- **SC-244-002**: In focused regression coverage, 100% of in-scope first-slice blocker and dominant-issue scenarios either render a matching help topic or degrade gracefully without errors or raw unresolved keys. +- **SC-244-003**: The machine-readable knowledge source contains only code-owned topic metadata and approved links, with 0 tenant-specific records, raw provider payloads, or secrets in regression coverage. +- **SC-244-004**: The adopted surfaces continue to use existing authorization semantics unchanged, with contextual help visible only after the host surface's existing entitlement checks succeed. diff --git a/specs/244-product-knowledge-contextual-help/tasks.md b/specs/244-product-knowledge-contextual-help/tasks.md new file mode 100644 index 00000000..ad109ac9 --- /dev/null +++ b/specs/244-product-knowledge-contextual-help/tasks.md @@ -0,0 +1,134 @@ +--- + +description: "Task list for Product Knowledge & Contextual Help" + +--- + +# Tasks: Product Knowledge & Contextual Help + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/checklists/requirements.md` (required) + +**Tests**: REQUIRED (Pest) for all runtime behavior changes in this slice. Keep proof in Unit + Feature lanes only. +**Operations**: This slice must not alter existing `OperationRun` start, completion, notification, or link UX. +**RBAC**: Existing onboarding, tenant, and support-diagnostic entitlement checks remain authoritative. No new capability family is introduced. +**Organization**: Tasks are grouped by user story so onboarding guidance, support-diagnostic guidance, and the internal machine-readable knowledge source remain independently deliverable. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Prepare the bounded product-knowledge namespace and the narrow validation surfaces. + +- [x] T001 Create the feature-local support namespace and test directories under `apps/platform/app/Support/ProductKnowledge/`, `apps/platform/tests/Unit/Support/ProductKnowledge/`, `apps/platform/tests/Feature/Onboarding/`, and `apps/platform/tests/Feature/SupportDiagnostics/` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the single shared contextual-help catalog and resolver before touching onboarding or support-diagnostic surfaces. + +**Checkpoint**: One bounded product-knowledge path exists before any host surface adopts it. + +- [x] T002 Create the code-owned first-slice topic catalog in `apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php` +- [x] T003 Create the shared resolver in `apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php` so help payloads are derived from `PlatformVocabularyGlossary`, `ReasonPresenter`, `OperatorExplanationBuilder`, and `RequiredPermissionsLinks` +- [x] T004 Define the minimal machine-readable knowledge-source metadata shape and unresolved-topic fallback contract inside the `ProductKnowledge` namespace so onboarding and support-diagnostic hosts can adopt the shared resolver before US3 hardens the final knowledge-source and fallback guarantees +- [x] T005 [P] Add unit coverage for all eight canonical topic keys, resolver behavior, and the foundational fallback/export contract in `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php`, `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php`, and `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php` +- [x] T006 Run the foundational unit suite with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php` + +--- + +## Phase 3: User Story 1 - Explain Onboarding Blockers In Context (Priority: P1) 🎯 MVP + +**Goal**: Show registry-backed contextual help directly in the onboarding workflow so operators can interpret the current blocker without founder explanation. + +**Independent Test**: Open onboarding drafts blocked by consent, permission, connection-health, and verification-freshness issues and verify that the wizard renders the matching help payload without changing the underlying readiness truth. + +### Tests for User Story 1 + +- [x] T007 [P] [US1] Add onboarding feature coverage for `admin-consent-required`, `required-permissions-missing`, `connection-unhealthy`, `verification-stale`, `verification-failed`, and positive/negative authorization behavior in `apps/platform/tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php` + +### Implementation for User Story 1 + +- [x] T008 [US1] Update `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` to derive contextual-help topic inputs from existing readiness, permission, and verification signals and resolve them through the shared `ContextualHelpResolver` +- [x] T009 [US1] Render the onboarding help block with native Filament/shared primitives inside `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, keeping the host workflow's existing action hierarchy and destructive actions unchanged +- [x] T010 [US1] Run the onboarding slice with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php` + +--- + +## Phase 4: User Story 2 - Reuse The Same Product Knowledge In Support Diagnostics (Priority: P1) + +**Goal**: Reuse the same contextual-help contract inside tenant and operation-context support-diagnostic previews. + +**Independent Test**: Open tenant and operation support diagnostics for the same dominant issue and verify that both previews render the same topic-backed help payload and degrade safely when a topic is missing. + +### Tests for User Story 2 + +- [x] T011 [P] [US2] Add support-diagnostic feature coverage for tenant-context and operation-context rendering of `admin-consent-required`, `required-permissions-missing`, `connection-unhealthy`, `verification-failed`, `diagnostic-evidence-incomplete`, `retryable-provider-failure`, and `manual-handoff-required` in `apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php` +- [x] T012 [P] [US2] Add authorization and missing-topic fallback coverage for support-diagnostic help in `apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php` + +### Implementation for User Story 2 + +- [x] T013 [US2] Update `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` to attach contextual-help payloads derived from dominant issue, provider state, and existing diagnostic summary inputs through the shared resolver +- [x] T014 [US2] Update the support-diagnostic preview hosts in `apps/platform/app/Filament/Pages/TenantDashboard.php` and `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` so they render the bundle's contextual-help data without introducing a second host-specific help dialect +- [x] T015 [US2] Run the support-diagnostic slice with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php` + +--- + +## Phase 5: User Story 3 - Provide A Safe Machine-Readable Knowledge Source (Priority: P2) + +**Goal**: Harden the scaffolded machine-readable knowledge source and fallback contract so the first-slice catalog stays safe for later internal AI/support reuse without turning the feature into AI execution or a public docs platform. + +**Independent Test**: Export the catalog into its machine-readable knowledge source and verify that it contains only topic metadata and approved links, while missing topics continue to degrade safely. + +### Tests for User Story 3 + +- [x] T016 [P] [US3] Extend `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php` and `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php` with finalized machine-readable knowledge-source assertions covering all eight canonical topic keys and approved-link metadata +- [x] T017 [P] [US3] Extend `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php` and `apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php` with finalized unresolved-topic, link-safety, and no-raw-key regressions + +### Implementation for User Story 3 + +- [x] T018 [US3] Finalize the machine-readable knowledge-source shape in `apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php` so all eight canonical topic keys expose only stable topic metadata, troubleshooting steps, glossary-backed copy, and approved links +- [x] T019 [US3] Harden `apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php` so unresolved topics never raise exceptions or leak raw keys into onboarding or support-diagnostic surfaces, building on the foundational contract from T004 +- [x] T020 [US3] Run the knowledge-source and fallback slice with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php` + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Lock down vocabulary alignment, formatting, and the narrow validation suite before implementation close-out. + +- [x] T021 [P] Confirm that first-slice topic keys, glossary nouns, and approved docs links stay aligned across `apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php`, `apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php`, and the adopted onboarding/support-diagnostic surfaces +- [x] T022 Run formatting on touched platform files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- [x] T023 Run the full narrow validation suite with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductKnowledge tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php` + +--- + +## Dependencies & Execution Order + +### User Story Dependency Graph + +```text +Phase 1 (Setup) + ↓ +Phase 2 (Catalog + resolver + fallback/export) + ↓ +US1 (onboarding help adoption) ───────────────┐ + ├─→ US3 (safe knowledge-source and fallback hardening) +US2 (support-diagnostic help adoption) ───────┘ +``` + +### Parallel Opportunities + +- The unit tests in Phase 2 can be authored in parallel once the catalog shape is agreed. +- Onboarding and support-diagnostic feature tests can be authored in parallel because they touch different host surfaces. +- US3 export and fallback hardening can proceed in parallel with late US1/US2 integration cleanup once the shared resolver contract is stable. + +--- + +## Test Governance Checklist + +- [ ] Lane assignment is named and is the narrowest sufficient proof for the changed behavior. +- [ ] New or changed tests stay in the smallest honest family, and no heavy-governance or browser family is introduced accidentally. +- [ ] Shared helpers and fixture setup remain cheap by default. +- [ ] Planned validation commands cover the change without pulling in unrelated lane cost. +- [ ] The adopted surfaces explicitly use `standard-native-filament` plus the named monitoring-state-page regression where required. +- [ ] No material budget or baseline escalation is introduced. -- 2.45.2 From 86505483bf3dec735963c41e0ad7bed7fe7a80fb Mon Sep 17 00:00:00 2001 From: ahmido Date: Mon, 27 Apr 2026 08:30:01 +0000 Subject: [PATCH 19/36] feat(customer-health): add decision card to tenant/workspace detail (spec 245) (#283) Add Customer Health decision card to tenant & workspace detail pages (spec 245). What I changed: - Render a decision-first Customer Health card on tenant and workspace detail pages. - Reuse `WorkspaceHealthSummaryQuery` and preserve `window` query param. - Update attention widget link text to "Review health details" and include `?window=`. - Add/adjust tests to cover new behavior and explainability. - Run Pint formatting. Compare URL: https://git.cloudarix.de/ahmido/TenantAtlas/compare/dev...245-customer-health-score Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/283 --- .specify/memory/constitution.md | 220 ++++- .specify/templates/checklist-template.md | 8 + .specify/templates/plan-template.md | 15 + .specify/templates/spec-template.md | 20 + .specify/templates/tasks-template.md | 18 + .../app/Filament/System/Pages/Dashboard.php | 8 + .../BuildsCustomerHealthDecisionData.php | 144 ++++ .../System/Pages/Directory/ViewTenant.php | 27 + .../System/Pages/Directory/ViewWorkspace.php | 27 + .../System/Widgets/CustomerHealthKpis.php | 46 ++ .../Widgets/CustomerHealthTopWorkspaces.php | 137 ++++ .../CustomerHealthDimensionCatalog.php | 112 +++ .../WorkspaceHealthSummaryQuery.php | 766 ++++++++++++++++++ .../customer-health-decision-card.blade.php | 59 ++ .../pages/directory/view-tenant.blade.php | 11 +- .../pages/directory/view-workspace.blade.php | 5 + .../customer-health-top-workspaces.blade.php | 53 ++ .../CustomerHealthAuthorizationTest.php | 171 ++++ .../CustomerHealthDashboardWidgetsTest.php | 186 +++++ .../CustomerHealthDetailDecisionTest.php | 158 ++++ .../CustomerHealthExplainabilityTest.php | 177 ++++ .../Spec114/ControlTowerDashboardTest.php | 20 +- .../SystemDirectoryResidualSurfaceTest.php | 3 +- .../CustomerHealthDimensionCatalogTest.php | 46 ++ .../WorkspaceHealthSummaryQueryTest.php | 414 ++++++++++ docs/product/spec-candidates.md | 200 ++++- docs/product/standards/README.md | 4 +- .../checklists/requirements.md | 46 ++ specs/245-customer-health-score/plan.md | 227 ++++++ specs/245-customer-health-score/spec.md | 339 ++++++++ specs/245-customer-health-score/tasks.md | 151 ++++ 31 files changed, 3772 insertions(+), 46 deletions(-) create mode 100644 apps/platform/app/Filament/System/Pages/Directory/Concerns/BuildsCustomerHealthDecisionData.php create mode 100644 apps/platform/app/Filament/System/Widgets/CustomerHealthKpis.php create mode 100644 apps/platform/app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php create mode 100644 apps/platform/app/Support/CustomerHealth/CustomerHealthDimensionCatalog.php create mode 100644 apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php create mode 100644 apps/platform/resources/views/filament/system/pages/directory/partials/customer-health-decision-card.blade.php create mode 100644 apps/platform/resources/views/filament/system/widgets/customer-health-top-workspaces.blade.php create mode 100644 apps/platform/tests/Feature/System/CustomerHealth/CustomerHealthAuthorizationTest.php create mode 100644 apps/platform/tests/Feature/System/CustomerHealth/CustomerHealthDashboardWidgetsTest.php create mode 100644 apps/platform/tests/Feature/System/CustomerHealth/CustomerHealthDetailDecisionTest.php create mode 100644 apps/platform/tests/Feature/System/CustomerHealth/CustomerHealthExplainabilityTest.php create mode 100644 apps/platform/tests/Unit/Support/CustomerHealth/CustomerHealthDimensionCatalogTest.php create mode 100644 apps/platform/tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php create mode 100644 specs/245-customer-health-score/checklists/requirements.md create mode 100644 specs/245-customer-health-score/plan.md create mode 100644 specs/245-customer-health-score/spec.md create mode 100644 specs/245-customer-health-score/tasks.md diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index d584fe80..ed48625f 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,30 +1,34 @@ - -> **Action Surface follow-up direction** — The action-surface contract foundation (Specs 082, 090) and the follow-up taxonomy/viewer specs (143–146) are all fully implemented. The remaining gaps are not architectural redesign — they are incomplete adoption, missing decision criteria, and scope boundaries that haven't expanded to cover all product surfaces. The correct shape is: one foundation amendment to codify the missing rules and extend contract scope (v1.1), two compliance rollout specs to enroll currently-exempted surface families, and one targeted correction to fix the clearest remaining anti-pattern on a high-signal surface. This avoids reinventing the architecture, avoids umbrella "consistency" specs, and produces bounded, independently shippable work. TenantResource lifecycle-conditional actions and PolicyResource More-menu ordering are addressed by the updated foundation rules, not by standalone specs. Widgets, choosers, and pickers remain deferred/exempt. - -### Action Surface Contract v1.1 — Decision Criteria, Ordering Rules, and System Scope Extension -- **Type**: foundation/spec amendment -- **Source**: row interaction / action surface architecture analysis 2026-03-16 -- **Problem**: The action-surface contract (Spec 082) establishes profiles, slots, affordances, validator tests, and guard tests — but does not codify three things: (1) formal decision criteria for when a surface should use ClickableRow vs ViewAction vs PrimaryLinkColumn as its inspect affordance; (2) ordering rules for actions inside the More menu (destructive-last, lifecycle position, stable grouping); (3) system-panel table surfaces are explicitly excluded from contract scope, meaning ~6 operational surfaces have no declaration and no CI coverage. The architecture is correct; it just cannot prevent inconsistent choices on new surfaces or catch drift on existing ones. -- **Why this is its own spec**: This is a foundation amendment — it changes the rules that all other surfaces must follow. Rollout specs (system panel enrollment, relation manager enrollment) depend on this spec's updated rules existing first. Merging rollout work into a foundation amendment blurs the boundary between "what the rules are" and "who must comply." -- **In scope**: - - Codify inspect-affordance decision tree (ClickableRow default, ViewAction exception criteria, PrimaryLinkColumn criteria) in `docs/ui/action-surface-contract.md` - - Define the "lone ViewAction" anti-pattern formally and add it to validator detection - - Codify More-menu action ordering rules (lifecycle actions, severity ordering, destructive-last) - - Extend contract scope so system-panel table surfaces are enrollable (not exempt by default) - - Add guidance that cross-panel surface taxonomy should converge where semantically equivalent - - Update `ActionSurfaceValidator` to enforce new criteria - - Update guard/contract tests to cover new rules -- **Non-goals**: - - Retrofitting all existing system-panel pages (separate rollout spec) - - Retrofitting all relation managers (separate rollout spec) - - One-off resource-level fixes (those are tasks within rollout or correction specs) - - TenantResource or PolicyResource redesign (addressed by applying the updated rules, not by dedicated specs) - - Chooser/picker/widget contracts (remain deferred/exempt) -- **Depends on**: Spec 082, Spec 090 (both fully complete — this extends their foundation) -- **Suggested order**: First. All other candidates in this cluster depend on the updated rules. -- **Risk**: Low. This adds rules and extends scope — it does not change existing compliant declarations. -- **Why this boundary is right**: Foundation rules must be codified before rollout enforcement. Mixing rule definition with compliance rollout makes it impossible to review the rules independently and creates circular dependencies. -- **Priority**: high - -### System Panel Action Surface Contract Enrollment -- **Type**: compliance rollout -- **Source**: row interaction / action surface architecture analysis 2026-03-16 -- **Problem**: System-panel table surfaces (Ops/Runs, Ops/Failures, Ops/Stuck, Directory/Tenants, Directory/Workspaces, Security/AccessLogs) use `recordUrl()` consistently but have no `ActionSurfaceDeclaration`, no CI coverage, and are exempt from the contract by default. They are the largest family of undeclared table surfaces in the product. -- **Why this is its own spec**: System-panel surfaces belong to a different panel with different operator audiences and potentially different profile requirements. Enrolling them is a distinct compliance effort from tenant-panel relation managers or targeted resource corrections. The scope is bounded and independently shippable. -- **In scope**: - - Declare `ActionSurfaceDeclaration` for each system-panel table surface (~6 pages) - - Map to existing profiles where semantically correct (e.g., `ListOnlyReadOnly` for access logs, `RunLog` for ops run tables) - - Introduce new system-specific profiles only if existing profiles truly do not fit - - Remove enrolled system-panel pages from `ActionSurfaceExemptions` baseline - - Add guard test coverage for enrolled system surfaces -- **Non-goals**: - - Tenant-panel resource declarations (already covered by Spec 090) - - Relation manager enrollment (separate candidate) - - Non-table system pages (dashboards, diagnostics, choosers) - - System-panel RBAC redesign - - Cross-workspace query authorization (tracked as "System Console Scope Hardening" candidate) -- **Depends on**: Action Surface Contract v1.1 (must extend scope to system panel first) -- **Suggested order**: Second, in parallel with "Run Log Inspect Affordance Alignment" after v1.1 is complete. -- **Risk**: Low. These surfaces already behave consistently; this work adds formal declarations and CI coverage. -- **Why this boundary is right**: System-panel enrollment is self-contained — it doesn't touch tenant-panel resources or relation managers. Completing it independently gives CI coverage over a currently-invisible surface family. -- **Priority**: medium - -### Relation Manager Action Surface Contract Enrollment -- **Type**: compliance rollout -- **Source**: row interaction / action surface architecture analysis 2026-03-16 -- **Problem**: Three relation managers (`BackupItemsRelationManager`, `TenantMembershipsRelationManager`, `WorkspaceMembershipsRelationManager`) are in the `ActionSurfaceExemptions` baseline with no declaration. They were exempted during initial rollout (Spec 090) because relation-manager-specific profile semantics were not yet settled. Three other relation managers already have declarations. The exemption should be reduced, not permanent. -- **Why this is its own spec**: Relation managers have different interaction expectations than standalone list resources (context is always nested under a parent record, pagination/empty-state semantics differ, attach/detach may replace create/delete in some cases). Enrollment requires relation-manager-specific review of profile fit, not just copying resource-level declarations. -- **In scope**: - - Declare `ActionSurfaceDeclaration` for each currently-exempted relation manager (3 components) - - Validate profile fit (`RelationManager` profile vs a more specific variant) - - Reduce `ActionSurfaceExemptions` baseline by removing enrolled relation managers - - Add guard test coverage -- **Non-goals**: - - Redesigning backup item management UX - - Redesigning membership management UX - - Parent resource changes (TenantResource, WorkspaceResource) - - Full restore/backup domain redesign - - Introducing new relation managers -- **Depends on**: Action Surface Contract v1.1 (for any updated profile guidance or relation-manager-specific ordering rules) -- **Suggested order**: Third, after both v1.1 and System Panel Enrollment are complete. Lowest urgency because these surfaces are low-traffic and already functionally correct. -- **Risk**: Low. These relation managers already work correctly. This adds formal compliance, not behavioral change. -- **Why this boundary is right**: Relation manager enrollment is a distinct surface family with its own profile semantics. Mixing it with system-panel enrollment or targeted resource corrections would create an unfocused rollout spec. -- **Priority**: low - -### Run Log Inspect Affordance Alignment -- **Type**: targeted surface correction -- **Source**: row interaction / action surface architecture analysis 2026-03-16 -- **Problem**: `OperationRunResource` declares the `RunLog` profile with `ViewAction` as its inspect affordance. In practice, it renders a lone `ViewAction` in the actions column — the "lone ViewAction" anti-pattern identified in `docs/ui/action-surface-contract.md`. The row-click-first direction means this surface should use `ClickableRow` drill-down to the canonical tenantless viewer (`OperationRunLinks::tenantlessView()`), not a standalone View button. This surface is also inherited by the `Monitoring/Operations` page (which delegates to `OperationRunResource::table()`), so the fix propagates to both surfaces. -- **Why this is its own spec**: This is the single highest-signal concrete violation of the action-surface contract direction. It is bounded to one resource declaration + one inherited page. It does not require rewriting the canonical viewer, redesigning the operations domain, or touching other monitoring surfaces. Keeping it separate from foundation amendments ensures it can ship quickly after v1.1 codifies the anti-pattern rule. -- **In scope**: - - Change `OperationRunResource` inspect affordance from `ViewAction` to `ClickableRow` - - Verify `recordUrl()` points to the canonical tenantless viewer - - Remove the lone `ViewAction` from the actions column - - Confirm the change propagates correctly to `Monitoring/Operations` (which delegates to `OperationRunResource::table()`) - - Update/add guard test assertion for the corrected declaration -- **Non-goals**: - - Rewriting the canonical operation viewer (Spec 144 already complete) - - Broad operations UX redesign - - All monitoring pages (Alerts, Stuck, Failures are separate surfaces with distinct interaction models) - - RestoreRunResource alignment (currently exempted — separate concern) - - Action hierarchy / More-menu changes on this surface (belong to a general rollout, not this correction) -- **Depends on**: Action Surface Contract v1.1 (for codified anti-pattern rule and ClickableRow-default guidance) -- **Suggested order**: Second, in parallel with "System Panel Enrollment" after v1.1 is complete. Quickest win and highest signal correction. -- **Risk**: Low. Single resource, no behavioral regression, no data model change. -- **Why this boundary is right**: One resource, one anti-pattern, one fix. Expanding scope to "all run-log surfaces" or "all operation views" would turn a quick correction into a rollout spec and delay the most visible improvement. -- **Priority**: medium - -### Selected-Record Monitoring Host Alignment -- **Type**: workflow compression -- **Source**: enterprise UX review 2026-04-19 — Finding Exceptions Queue and Audit Log selected-record monitoring surfaces -- **Problem**: Specs 193 and 198 correctly established the semantics for `queue_workbench` and `selected_record_monitoring`, but they intentionally stopped at action hierarchy and page-state transport. The remaining gap is the active review host shape. `FindingExceptionsQueue` and `AuditLog` both preserve selection via query parameter and `inspect_action`, yet the current host experience still sits awkwardly between a list page, an inline expanded detail block, and a modal-style inspect affordance. That is technically valid, but it does not read as an enterprise-grade workbench. Operators get shareable URLs and refresh-safe state, but not a clearly expressed review mode with one deliberate place for context, next step, and close/return behavior. -- **Why it matters**: Enterprise operators working through queues or history need one of two unmistakable behaviors: either remain in a stable workbench where list context and active record review coexist intentionally, or leave the list for a canonical detail route with explicit return continuity. The current halfway pattern preserves state better than a slide-over, but it still weakens scanability, makes the active review lane feel bolted on, and leaves too much room for future local variations across monitoring surfaces. -- **Proposed direction**: - - Define two allowed enterprise host models for `selected_record_monitoring` surfaces: - - **Split-pane workbench**: the list, filters, and queue context remain continuously visible while the selected record occupies a dedicated persistent review pane - - **Canonical detail route**: the list remains list-first, and inspect opens a standalone detail page with explicit back/return continuity and optional preserved filter state - - Allow **quick-peek overlays** only as optional preview affordances, never as the sole canonical inspect or deep-link contract - - Add host-selection criteria so surfaces choose deliberately between split-pane and canonical detail route instead of drifting into full-page inline "focused lane above the table" patterns - - Pilot the rule on `FindingExceptionsQueue` and `AuditLog`, keeping current query-param addressability while upgrading the actual review host ergonomics - - Codify close/back/new-tab/reload semantics and invalid-selection fallback per host model so URL durability and review ergonomics are aligned rather than accidental -- **Smallest enterprise-capable version**: Limit the first slice to the two already-real `selected_record_monitoring` surfaces in Monitoring: `FindingExceptionsQueue` and `AuditLog`. The spec should choose and implement one clear host model per surface, document the decision rule, and stop there. No generic pane framework, no broad monitoring IA rewrite, and no rollout to unrelated list/detail pages. -- **Explicit non-goals**: Not a full Monitoring redesign, not a new modal framework, not a replacement for Spec 198 page-state semantics, not a generic shared-detail engine, not a broad action-surface retrofit outside `selected_record_monitoring`, and not a rewrite of finding or audit domain truth. -- **Permanent complexity imported**: One small host-pattern contract for `selected_record_monitoring`, explicit decision criteria for split-pane vs canonical detail route, focused regression coverage for two surfaces, and a small amount of new vocabulary around host model choice. No new persisted truth, no new provider/runtime architecture, and no new generalized UI platform are justified. -- **Why now**: The product already has at least two real consumers of the same selected-record monitoring pattern, and one of them is visible enough that the UX gap is now obvious. Leaving the gap open means future monitoring surfaces will keep re-solving the same question locally, and the currently correct page-state work will continue to feel less enterprise than it should. -- **Why not local**: A one-off polish pass on `FindingExceptionsQueue` would not answer what `AuditLog` should do, nor would it define when a selected-record monitoring surface should stay list-first versus move to canonical detail. The missing artifact is not just layout polish; it is the host decision rule for a small but real surface family. -- **Approval class**: Workflow Compression -- **Red flags triggered**: One red flag: this introduces a cross-surface host-model rule. The scope must stay bounded to the already-real `selected_record_monitoring` family and must not grow into a general monitoring-shell framework. -- **Score**: Nutzen: 2 | Dringlichkeit: 1 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12** -- **Decision**: approve -- **Acceptance points**: - - Each `selected_record_monitoring` surface declares one deliberate host model instead of expressing active review as an ad hoc inline expansion - - Deep links, refresh, and invalid-selection fallback remain stable after the host upgrade - - Operators can keep queue/history context while reviewing a record, or return to it predictably when the chosen host model uses a dedicated detail route - - Close, back, related drilldowns, and "open in full detail" semantics become consistent enough that selected-record monitoring feels like a product pattern instead of a local layout choice -- **Dependencies**: Spec 193 (`monitoring-action-hierarchy`), Spec 198 (`monitoring-page-state`), and the existing Monitoring page-state guards already in the repo -- **Related specs / candidates**: Spec 197 (`shared-detail-contract`), Action Surface Contract v1.1, Admin Visual Language Canon, Record Page Header Discipline & Contextual Navigation (for return semantics only; not a direct dependency) -- **Priority**: medium - -### Admin Visual Language Canon — First-Party UI Convention Codification and Drift Prevention -- **Type**: foundation -- **Source**: admin UI consistency analysis 2026-03-17 -- **Problem**: TenantPilot has accumulated a strong set of first-party visual conventions across Filament resources, widgets, detail pages, badges, status indicators, action hierarchies, and operational surfaces. These conventions are emerging organically and are already broadly consistent — but they remain implicit. No canonical reference defines when to use native Filament patterns vs custom enterprise-detail compositions, which badge/status semantics apply to which domain states, how timestamps should render (`since()` vs absolute datetime vs contextual format), what the card/section/surface hierarchy rules are, which widget composition strategies are canonical, or where cross-panel visual divergence is intentional vs accidental. As the product's surface area grows — new policy families, new governance domains, new operational pages, new evidence/reporting surfaces — the risk is not current visual chaos but future drift caused by missing written selection criteria and decision rules. -- **Why it matters**: Without a codified visual language reference, each new surface is a local design decision made without canonical guidance. This produces slow, cumulative inconsistency that becomes expensive to correct retroactively and degrades enterprise UX credibility. The problem is amplified by multi-agent development: multiple contributors (human and AI) cannot converge on implicit conventions they haven't seen documented. The value is not aesthetic — it is architectural: a canonical reference prevents divergent local choices, reduces review friction, accelerates new surface development, and establishes a stable foundation for the product's long-term visual identity without introducing third-party theme dependencies. -- **Proposed direction**: - - Codify the existing first-party admin visual conventions as a canonical reference document (e.g. `docs/ui/admin-visual-language.md` or similar), covering: - - Badge/status semantics: color mapping rules, icon usage criteria, domain-specific badge extraction patterns, when to use Filament native badge vs custom status composition - - Timestamp rendering: decision rules for `since()` (relative) vs absolute datetime vs contextual format, with domain-specific overrides where justified - - Action hierarchy: primary action vs header actions vs row actions vs bulk actions presentation conventions (complementing the Action Surface Contract's interaction-level rules with visual-level guidance) - - Widget composition: selection criteria for stat cards, chart widgets, list widgets, and custom compositions; density and grouping rules - - Surface/card/section hierarchy: when to use native Filament sections vs custom detail cards vs grouped infoblocks; nesting and visual weight rules - - Enterprise-detail page composition: canonical structure for entity detail/view pages (header, metadata, status, content sections, related data) - - Cross-panel visual divergence: explicit rules for where admin-panel and system-panel styling may diverge and where they must converge - - Typography and spacing: canonical use of Filament's built-in text scales and spacing tokens; rules against ad hoc inline styles - - Establish guardrails against ad hoc local visual overrides (documented anti-patterns, PR review checklist items, or lightweight CI checks where practical) - - Explicitly state that native Filament v5 configuration and CSS hook classes remain the primary styling foundation; a thin first-party theme layer is only justified if native configuration proves insufficient for a documented, bounded set of requirements - - Explicitly reject third-party theme packages (e.g. Filament theme marketplace packages) as an architectural baseline unless separately justified by a dedicated evaluation spec with clear acceptance criteria - - Where existing conventions have already diverged, define the canonical choice and flag surfaces that need alignment (as future cleanup tasks, not as part of this spec's implementation scope) -- **In scope**: - - Inventory of existing visual conventions across tier-1 admin surfaces (resources, detail pages, dashboards, operational views) - - Canonical reference document with decision rules and examples - - Anti-pattern catalog (known visual drift patterns to avoid) - - Lightweight enforcement strategy (review checklist, optional CI, or validator approach) - - Explicit architectural position on theme dependencies -- **Out of scope**: - - Visual redesign of any existing surface (this is codification, not redesign) - - Aesthetic refresh or "make it look nicer" polish work - - Third-party theme evaluation, selection, or integration - - Broad Filament view publishing or deep customization layer - - Marketing/branding/identity work (this is internal admin UX, not external brand) - - Color palette redesign or new design-system creation - - Retrofitting all existing surfaces to strict compliance (alignment cleanup is tracked separately per surface) -- **Key architectural positions**: - - Native Filament v5 remains the primary visual foundation. The product's visual identity is expressed through intentional use of native Filament configuration, not through override layers. - - CSS hook classes are the canonical customization mechanism where native configuration is insufficient. No publishing of Filament internal views for styling purposes. - - The main gap is missing canonical reference and decision rules, not missing components or missing technology. - - The value proposition is preventing future UI drift as more surfaces are added, not correcting a current visual crisis. -- **Dependencies**: Action Surface Contract (Spec 082 / v1.1 candidate) for interaction-level conventions that this visual-level reference complements but does not duplicate. Operations Naming Harmonization candidate for operator-facing terminology alignment that is a distinct concern from visual conventions. -- **Related candidates**: Action Surface Contract v1.1, Operations Naming Harmonization, Help Center / Documentation Surface (the visual language reference could eventually link from contextual help) -- **Trigger / best time to do this**: Before the next wave of new governance domain surfaces (Entra Role Governance, Enterprise App Governance, SharePoint Sharing Governance, Evidence Domain) and before the Policy Setting Explorer UX, so those surfaces are built against documented canonical conventions rather than best-effort pattern matching. -- **Risks if ignored**: Slow visual drift across surfaces, increasing review friction for new surfaces, divergent local conventions that become expensive to reconcile, weakened enterprise UX credibility as surface count grows, and higher cost of eventual systematic alignment. -- **Priority**: medium - -### Surface Signal-to-Noise Optimization — Metadata Hierarchy and Information Density -- **Type**: hardening -- **Source**: UI/UX audit — consistency and noise reduction analysis -- **Problem**: Across TenantPilot's operator-facing list pages, detail views, and table surfaces, secondary metadata (timestamps, technical identifiers, raw provider keys, policy family labels, scope tag counts) often renders with the same visual prominence as primary content (policy name, status, outcome, tenant name). This creates a "wall of equal-weight information" where operators must mentally parse every row to find the signal that matters. Specific patterns: (1) date/time format inconsistency — some surfaces use `since()` (relative time), others use absolute datetime, with no clear rule for when each is appropriate; (2) technical identifiers (Graph API entity IDs, internal run IDs, provider-specific keys) surface in columns or info entries where they add no operator value; (3) badge/indicator density — some table rows have 3–4 badges (status, outcome, type, provider) where a simpler hierarchy would communicate the same information with less cognitive load; (4) column label and truncation inconsistency — overlong policy names, setting paths, or assignment descriptions push column layouts into horizontal scroll or wrap awkwardly without consistent truncation conventions; (5) metadata-to-action ratio — some detail/view pages dedicate more viewport space to metadata fields than to the actionable governance information the page exists to serve. -- **Why it matters**: Enterprise governance UX credibility depends on perceived information quality, not just data completeness. A surface that shows everything with equal visual weight communicates "we don't know what's important" to operators processing dozens of policies, hundreds of operation runs, or fleet-level tenant summaries daily. Noise reduction is not aesthetics — it is usability: faster scanning, fewer misreadings, lower cognitive fatigue, and higher confidence that the visible information is the information that matters. This is particularly important as TenantPilot adds more governance domains (Entra roles, enterprise apps, compliance baselines, evidence surfaces) — each new domain adds columns, badges, and metadata fields, and without a noise-reduction pass, information density will compound rather than degrade gracefully. -- **Proposed direction**: - - **Date/time format decision rules**: codify when to use relative time (`since()`, "2 hours ago"), when to use absolute datetime, and when to use contextual format (date only for old entries, time only for today). Apply consistently across all list and detail surfaces. Relative time is appropriate for recency-sensitive contexts (last sync, last backup, operation age); absolute datetime is appropriate for audit/evidence contexts (created_at, snapshot timestamp). Both should render with appropriate visual weight (secondary text style for timestamps in list columns, not primary text weight). - - **Technical identifier suppression**: audit list and detail surfaces for raw IDs, Graph API entity IDs, and internal identifiers that serve no operator purpose. Suppress or move to expandable/copyable detail panels. Operators should see human-readable labels, not internal keys, unless they explicitly request technical detail. - - **Badge density reduction**: establish a per-row badge budget or hierarchy rule — primary status badge, optional outcome badge, context labels where needed, but not every possible dimension as a visible badge. Secondary indicators can be tooltips, expandable detail, or column values instead of badges. - - **Truncation and column conventions**: define consistent truncation rules for long text fields in table columns (policy names, setting paths, assignment descriptions). Prefer tooltip-on-truncation over horizontal scroll. Define maximum comfortable column count for primary list surfaces. - - **Metadata visual weight hierarchy**: establish a 3-tier visual weight system for metadata: primary (name, status — full weight), secondary (type, updated_at, tenant — reduced weight, secondary text color), tertiary (ID, raw key, scope tag count — hidden by default, available on demand). Apply across list and detail surfaces. -- **In scope**: metadata visual weight hierarchy rules, date/time format conventions, technical identifier suppression audit, badge density guidelines, truncation conventions, affected surface inventory, implementation of conventions on highest-traffic surfaces -- **Out of scope**: visual redesign or aesthetic refresh (this is hierarchy and noise reduction, not a design overhaul), Admin Visual Language Canon codification (which writes down the full visual convention set — this candidate solves one specific problem within that space), empty states (covered by Spec 122), action hierarchy (covered by Action Surface Contract), new component development, third-party theme integration -- **Boundary with Admin Visual Language Canon**: The Canon is a codification and drift-prevention effort — it documents all visual conventions to prevent future divergence. This candidate identifies and resolves a specific, measurable UX problem (excess metadata noise) that exists today. The solutions from this candidate should feed into the Canon's documented conventions, but this candidate produces concrete UX improvements, not just documentation. The Canon defines the rules; this candidate fixes the violations that currently hurt operator experience. -- **Boundary with Operator Presentation & Lifecycle Action Hardening**: That candidate owns shared rendering conventions for operation labels and status badges via centralized abstractions (OperationCatalog, BadgeRenderer). This candidate owns the broader question of how much metadata should be visible and at what visual weight — a problem that extends beyond operations to all governance surfaces. -- **Boundary with Spec 122 (Empty State Consistency)**: Empty states address surfaces with no data. This candidate addresses surfaces with data that presents too much secondary information with too little hierarchy. Complementary problems at opposite ends of the information-density spectrum. -- **Dependencies**: Admin Visual Language Canon (soft dependency — conventions established here should align with or feed into the Canon), existing Filament table/infolist infrastructure, BadgeRenderer/BadgeCatalog (for badge rendering conventions) -- **Related candidates**: Admin Visual Language Canon (complementary — visual convention codification), Operator Presentation & Lifecycle Action Hardening (complementary — rendering conventions), Spec 122 (complementary — empty state consistency) -- **Priority**: medium - -### Infrastructure & Platform Debt — CI, Static Analysis, Test Parity, Release Process -- **Type**: hardening -- **Source**: roadmap-to-spec coverage audit 2026-03-18, Infrastructure & Platform Debt table in `docs/product/roadmap.md` -- **Problem**: TenantPilot's product architecture and governance domain have matured significantly, but the surrounding delivery infrastructure has not kept pace. The roadmap acknowledges six open infrastructure debt items — no CI pipeline, no static analysis (PHPStan/Larastan), SQLite-for-tests vs. PostgreSQL-in-production schema drift risk, no `.env.example`, no formal release process, and Dokploy configuration external to the repo — but none of these has a planning home or a specifiable path to resolution. Individually, each is a small-to-medium task. Collectively, they represent a real delivery confidence and maintainability gap: regressions are caught manually, schema drift between test and runtime is a known risk, deploys are manual, there is no static analysis baseline, and developer onboarding has unnecessary friction. As surface area and contributor count grow, this gap becomes more expensive and more dangerous. -- **Why it matters**: Delivery infrastructure is the foundation that makes product-level correctness sustainable. Without CI, regressions that product architecture hardening work has eliminated can silently return. Without static analysis, type-safety gains from PHP 8.4 and strict Filament/Livewire patterns are unenforced. Test/runtime environment parity gaps mean that passing tests do not prove production correctness — a particularly dangerous problem for a product that governs enterprise tenant configurations. No formal release process means deploy confidence depends on human discipline, which degrades as velocity increases. These are not individually urgent, but they are collectively a prerequisite for scaling the product safely. A platform that governs enterprise Intune tenants should have its own delivery governance in order. -- **Proposed direction**: - - **CI pipeline**: establish a CI configuration (compatible with Gitea runners or external CI) that runs the test suite, Pint formatting checks, and (once added) static analysis on every push/PR. Start with a minimal pipeline that provides a pass/fail quality gate rather than a complex multi-stage build system. The goal is "every merge request is automatically validated" — not a full platform engineering initiative. - - **Static analysis baseline**: introduce PHPStan or Larastan at a pragmatic starting level (e.g. level 5–6), baselined against the current codebase. Focus on catching type errors, undefined method calls, and incorrect return types. Do not aim for level-max compliance as a first step — establish the tool, baseline the noise, and raise the level incrementally. - - **Test/runtime environment parity**: resolve the SQLite-for-tests vs. PostgreSQL-in-production gap. The existing `phpunit.pgsql.xml` suggests this work is partially started. The goal is that the default test suite runs against the same database engine used in production, so that schema-level and query-level differences do not create silent correctness gaps. This is particularly important for JSONB-dependent domains (policy snapshots, backup payloads, operation context). - - **Developer onboarding hygiene**: add `.env.example` with documented defaults. Small but persistent friction item that affects new contributor experience and reduces setup-related support burden. - - **Release process formalization**: define a lightweight, documented release process covering version tagging, migration verification, asset compilation (`filament:assets`, `npm run build`), and staging-to-production promotion checks. Not a full release engineering overhaul — a minimal repeatable process that replaces purely manual deploys with a documented, verifiable workflow. - - **Deployment configuration traceability**: evaluate bringing essential Dokploy/deploy configuration into the repo (or at minimum documenting the external configuration surface) so that environment drift between staging and production is detectable rather than discovered after deployment. -- **Explicit non-goals**: Not a full platform engineering or DevOps transformation initiative. Not a rewrite of deployment architecture or infrastructure provisioning. Not a generic "clean up the repo" bucket for unrelated code quality tasks. Not a replacement for product-level architecture hardening work (queued execution reauthorization, Livewire context locking, etc. are distinct product-safety concerns). Not a mandate to achieve maximum static analysis strictness immediately. Not a CI/CD feature-flag or canary-deployment system. Not an internal developer tooling platform with custom CLIs, dashboards, or abstraction layers. The scope is bounded to the six concrete items identified in the roadmap's Infrastructure & Platform Debt table, plus the minimal CI/release process that connects them into an actionable delivery improvement. -- **Boundary with product architecture hardening (Queued Execution Reauthorization, Livewire Context Locking, etc.)**: Product hardening candidates address trust, authorization, and isolation correctness in the running application. Infrastructure debt addresses delivery confidence — the tooling and process that ensures correctness is verified continuously and shipped reliably. These are complementary layers: product hardening fixes what the code does; infrastructure maturity ensures the fixes stay fixed. -- **Boundary with Operations Naming Harmonization**: Operations naming is about operator-facing terminology consistency across product surfaces. Infrastructure debt is about developer-facing delivery tooling and process. Different audiences, different concerns. -- **Boundary with Admin Visual Language Canon**: The visual language canon mentions lightweight CI enforcement as a possible delivery mechanism for visual convention compliance. If this infrastructure candidate delivers CI, the visual canon can use it — but the CI pipeline itself is infrastructure scope, not visual-canon scope. -- **Dependencies**: None — this is foundational work that other candidates can build on. CI pipeline benefits every future spec by providing automated regression coverage. Static analysis benefits every future hardening spec by enforcing type-safety contractually. -- **Priority**: medium (high cumulative value for delivery confidence and maintainability, but individual items are execution-level tasks rather than product-architecture blockers; should be prioritized pragmatically alongside product work rather than treated as urgent or deferred indefinitely) - ---- - -## Covered / Absorbed - -> Candidates that were previously qualified but are now substantially covered by existing specs, or were umbrella labels whose children have been promoted individually. - -### Governance Architecture Hardening Wave (umbrella — dissolved) -- **Original source**: architecture audit 2026-03-15 -- **Status**: Dissolved into individual follow-ups. Queued Execution Reauthorization is now Spec 149, Livewire Context Locking is now Spec 152, Tenant-Owned Query Canon remains the only child still tracked as its own open candidate, and Findings Workflow Enforcement is absorbed below. -- **Reference**: [../audits/tenantpilot-architecture-audit-constitution.md](../audits/tenantpilot-architecture-audit-constitution.md), [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md) - -### Evidence Completeness Reclassification -- **Original source**: semantic clarity & operator-language audit 2026-03-21 -- **Status**: Do not promote as a separate candidate. This follow-up is absorbed into existing Spec 153 (Evidence Domain Foundation), which should carry the semantic split between coverage, freshness, valid-empty states, and operator-facing completeness language. - -### Operation Outcome & Notification Language -- **Original source**: semantic clarity & operator-language audit 2026-03-21 -- **Status**: Prefer extending existing Spec 055 (Ops-UX Constitution Rollout) rather than creating a standalone semantic candidate, as long as Spec 055 is still the active vehicle for operation outcome presentation, partial-success messaging, blocked-cause guidance, and terminal notification language. - -### Tenant Review & Publication Readiness Semantics -- **Original source**: semantic clarity & operator-language audit 2026-03-21 -- **Status**: Do not create a standalone candidate. This follow-up is absorbed into existing Spec 155 (Tenant Review Layer), which should own review completeness vocabulary, publication-readiness blocker wording, freshness semantics, and review-layer next-action language. - -### Findings Workflow Enforcement and Audit Backstop -- **Original source**: architecture audit 2026-03-15, candidate C -- **Status**: Absorbed by Spec 111 (findings workflow v2) and the narrower hardening follow-up Spec 151 (`findings-workflow-backstop`). The remaining enforcement gap is no longer candidate-sized unless a new regression or audit finding re-opens it. - -### Workspace Chooser v2 -- **Original source**: Spec 107 deferred backlog -- **Status**: Workspace chooser v1 is covered by Spec 107 + semantic fix in Spec 121. The v2 polish items (search, sort, favorites, pins, environment badges) remain tracked as an Inbox entry. Not qualified as a standalone spec candidate at current priority. - -### Dashboard Polish (Enterprise-grade) -- **Original source**: Product review 2026-03-08 -- **Status**: Core tenant dashboard is covered by Spec 058 (drift-first KPIs, needs attention, recent lists). Workspace-level landing is in progress via Spec 129. The remaining polish items (sparklines, compliance gauge, progressive disclosure) are tracked in Inbox. This was demoted because the candidate lacked a bounded spec scope — it read as a wish list rather than a specifiable problem. - -### Scope & Navigation Semantics (UI/UX Audit) -- **Original source**: UI/UX audit — scope and navigation semantics analysis -- **Status**: Comprehensively covered by existing spec constellation. Spec 077 (implemented) established workspace-first navigation, monitoring hub IA, header context bar, and tenant-context default filters. Spec 103 (draft) addresses IA scope-vs-filter-vs-targeting semantics on monitoring pages. Spec 121 (draft) fixes workspace switch routing semantics. Spec 106 (draft) corrects sidebar navigation context visibility. Spec 107 (draft) covers workspace chooser v1. Spec 129 (draft) addresses workspace home and admin landing pages. Spec 143 (draft) covers tenant lifecycle, operability, and context semantics. Spec 144 (draft) addresses canonical operation viewer context decoupling. Spec 131 (draft) covers cross-resource navigation and drill-down cohesion. The audit's navigation/scope concerns are distributed across these specs as precisely bounded, spec-level problems — no new umbrella candidate is needed. -- **Reference specs**: 077, 103, 106, 107, 121, 129, 131, 143, 144 - -### Detail Page Hierarchy & Progressive Disclosure (UI/UX Audit) -- **Original source**: UI/UX audit — detail page hierarchy and progressive disclosure analysis -- **Status**: Directly covered by Spec 133 (View Page Template Standard for Enterprise Detail Screens). Spec 133 defines the shared enterprise detail-page composition standard including summary-first header, main-and-supporting layout, dedicated related-context section, secondary technical detail separation, optional section support, and degraded-state resilience. Spec.md, plan.md, research.md, data-model.md, and tasks.md (all tasks complete) exist for 4 initial target pages (BaselineSnapshot, BackupSet, EntraGroup, OperationRun). If additional pages require alignment beyond the initial 4 targets, that is a Spec 133 follow-up scope extension, not a new candidate. -- **Reference specs**: 133 - -### Record Page Header Discipline & Contextual Navigation -- **Original source**: Constitution compliance audit 2026-04 -- **Status**: Promoted to Spec 192 (`record-header-discipline`). No longer tracked as an open candidate. -- **Reference specs**: 192 - -### Monitoring Surface Action Hierarchy & Workbench Semantics -- **Original source**: Constitution compliance audit 2026-04 -- **Status**: Promoted to Spec 193 (`monitoring-action-hierarchy`). No longer tracked as an open candidate. -- **Reference specs**: 193 - -### Governance Friction & Operator Vocabulary Hardening -- **Original source**: Constitution compliance audit 2026-04 -- **Status**: Promoted to Spec 194 (`governance-friction-hardening`). No longer tracked as an open candidate. -- **Reference specs**: 194 - -> **UI Discipline Trilogy — Sequencing Note** -> -> These three candidates formed a coordinated trilogy and are now represented by Specs 192, 193, and 194: -> -> 1. **Record Page Header Discipline & Contextual Navigation** — largest visible lever; establishes the binding header-action contract for all Record/Detail pages -> 2. **Monitoring Surface Action Hierarchy & Workbench Semantics** — separates Workbench/Queue surfaces from Record page rules; defines the action hierarchy for Monitoring surfaces -> 3. **Governance Friction & Operator Vocabulary Hardening** — targeted finishing step for friction, reason capture, and vocabulary consistency -> -> **Why this order:** Record pages are the most numerous and most directly visible gap. Monitoring surfaces need their own rules (not a Record page derivative). Governance friction is the smallest scope and benefits from the architectural cleanup of the first two specs. -> -> **Why three specs instead of one:** Each has different affected surfaces, different interaction models, and different implementation patterns. Merging them would create an unshippable monolith. Keeping them sequenced preserves independent delivery while converging on one coherent UI discipline. - ---- - -## Planned - -> Ready for spec creation. Waiting for slot in active work. - -*(empty — move items here when prioritized for next sprint)* - ---- - -## Template - -```md -### Title -- **Type**: feature | polish | hardening | bug | research -- **Source**: chat | audit | coding discovery | customer feedback | spec N follow-up -- **Problem**: -- **Why it matters**: -- **Proposed direction**: -- **Dependencies**: -- **Priority**: low | medium | high -``` - -### Customer Review Workspace v1 -- **Type**: product / customer-facing governance review -- **Source**: platform strategy review 2026-04-24 — internal tenant reviews, evidence, findings, exceptions, and review packs are present, but customer-facing consumption remains under-specified -- **Problem**: TenantPilot already has internal governance artifacts such as tenant reviews, evidence snapshots, findings, accepted risks, and review packs. However, the value remains too operator-internal unless customer members or review recipients can safely consume released review results without admin access. -- **Why it matters**: For MSPs and audit-sensitive customers, a read-only review surface turns internal governance work into a sellable recurring service. Customers need to see baseline status, open findings, accepted risks, review history, and downloadable evidence or review packs without being exposed to admin actions or raw diagnostic internals. -- **Proposed direction**: - - provide a read-only customer/member review workspace per tenant - - show baseline status and latest review summary - - show open findings, resolved findings, and accepted risks - - expose released review packs and evidence downloads - - apply customer-safe redaction rules - - hide all admin, remediation, provider, and destructive actions - - preserve drilldown only into released, customer-safe artifacts -- **Scope boundaries**: - - **In scope**: read-only review dashboard, released tenant-review visibility, evidence-pack links, accepted-risk visibility, customer-safe copy, RBAC visibility rules - - **Out of scope**: customer policy changes, remediation actions, full collaboration/chat, bidirectional ticket portal, public anonymous sharing, external auditor portal -- **Dependencies**: Tenant Review Layer, Evidence Domain Foundation, Finding Risk Acceptance Lifecycle, Review Pack generation, RBAC, workspace/customer membership model -- **Acceptance points**: - - Customer members can only see released review data for their tenant - - Customer members cannot trigger admin or remediation actions - - Review packs and evidence links respect redaction and visibility rules - - Findings and accepted risks are understandable without raw operator diagnostics - - MSP/internal operators retain full admin surfaces separately -- **Roadmap fit**: Release 2 — Tenant Reviews, Evidence Packs & Control Foundation; sharpens the current Customer Read-only View into a concrete review-consumption surface. -- **Priority**: high +- Findings Notifications & Escalation v1 -> Spec 224 (`findings-notifications-escalation`) +- Assignment Hygiene & Stale Work Detection -> Spec 225 (`assignment-hygiene`) +- Findings Notification Presentation Convergence -> Spec 230 (`findings-notification-convergence`) +- Finding Outcome Taxonomy & Verification Semantics -> Spec 231 (`finding-outcome-taxonomy`) +- Operation Run Link Contract Enforcement -> Spec 232 (`operation-run-link-contract`) +- Operation Run Active-State Visibility & Stale Escalation -> Spec 233 (`stale-run-visibility`) +- Provider Boundary Hardening -> Spec 237 (`provider-boundary-hardening`) + +## Superseded / Removed From Active Queue + +These items were previously open candidates or roadmap-fit ideas, but should no longer stay in the active queue. + +- `R2.0 Canonical Control Catalog Foundation`: remove from active candidates because the ledger shows a repo-real catalog, config, bindings, review integration, and test coverage. This is no longer an open candidate; it is an implemented foundation. +- `Self-Service Tenant Onboarding & Connection Readiness`: remove from active candidates because it is already Spec 240 and the repo already shows meaningful adoption. +- `Support Diagnostic Pack`: remove from active candidates because it is already Spec 241 and repo-adopted. +- `Operational Controls & Feature Flags`: remove from active candidates because it is already Spec 242 and repo-adopted. +- `Product Usage & Adoption Telemetry`: remove from active candidates because it is already Spec 243 and repo-adopted. +- `Product Knowledge & Contextual Help`: remove from active candidates because it is already Spec 244; any remaining work should be narrower follow-ups, not a repeated top-level candidate. +- `Customer Health Score`: remove from active candidates because it is already Spec 245 and repo-adopted. +- `In-App Support Request with Context`: remove from active candidates because it is already Spec 246 and repo-implemented. +- `Plans, Entitlements & Billing Readiness`: remove as a broad active candidate because Spec 247 already exists and the remaining open gap is narrower commercial lifecycle maturity. +- `Private AI Execution & Policy Foundation`: remove from the active queue because Spec 248 already exists. +- Company-ops items such as `Lead Capture & CRM Pipeline`, `AVV / DPA / TOM / Legal Pack`, `Vendor Questionnaire Answer Bank`, `Business Continuity / Founder Backup Plan`, and similar operating artifacts should remain outside the active product-spec queue unless a concrete product slice emerges. diff --git a/specs/043-cross-tenant-compare-and-promotion/checklists/requirements.md b/specs/043-cross-tenant-compare-and-promotion/checklists/requirements.md new file mode 100644 index 00000000..df9f95c8 --- /dev/null +++ b/specs/043-cross-tenant-compare-and-promotion/checklists/requirements.md @@ -0,0 +1,57 @@ +# Specification Quality Checklist: Cross-Tenant Compare Preview and Promotion Preflight + +**Purpose**: Validate full preparation-package completeness and implementation readiness before the feature moves into the implementation loop +**Created**: 2026-04-27 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] Business value and operator outcome stay explicit +- [x] The slice is tightly bounded to compare preview, promotion preflight, and portfolio launch continuity +- [x] Runtime-governance sections are present for an implementation-ready package +- [x] All mandatory sections are completed in `spec.md`, `plan.md`, and `tasks.md` + +## Requirement Completeness + +- [x] No `[NEEDS CLARIFICATION]` markers remain +- [x] Requirements are testable and unambiguous +- [x] Acceptance scenarios are defined for compare preview, read-only promotion preflight, and launch/return continuity +- [x] Edge cases are identified, including explicit rejection of same-tenant compare, cross-workspace attempts, lost entitlement, ambiguous identity, and stale target evidence +- [x] Scope is clearly bounded away from actual promotion execution, queues, persisted drafts, mapping automation, customer-facing compare, and multi-provider work +- [x] Dependencies, assumptions, risks, and follow-up candidates are identified + +## Feature Readiness + +- [x] The first slice is small enough for a bounded implementation loop +- [x] Concrete repo surfaces are named for compare reuse, portfolio launch, audit reuse, and likely new compare support files +- [x] Foundational work stays preparation-only and does not imply execution scope or new persistence +- [x] The tasks are ordered, testable, and grouped by user story +- [x] No unresolved product question blocks implementation once artifact analysis passes + +## Governance Readiness + +- [x] Workspace and tenant isolation rules are explicit, including `404` for non-members and out-of-scope tenants +- [x] The capability matrix is explicit: page access = `WORKSPACE_BASELINES_VIEW`, preview data = `TENANT_VIEW` on both tenants, preflight execution = `WORKSPACE_BASELINES_MANAGE`, and manage-denied members see a disabled preflight action with permission guidance +- [x] Promotion remains preflight-only, with no write execution, queue, or `OperationRun` +- [x] Audit remains bounded to promotion-preflight entry points with no new compare/promotion persistence truth +- [x] Livewire v4 and Filament v5 compliance, unchanged provider registration in `bootstrap/providers.php`, no new global-search resource, and no new asset strategy are explicit in the package + +## Test Governance Review + +- [x] Lane fit stays in focused `Unit` plus `Feature` validation only +- [x] Fixture and helper growth stays local to compare preview, preflight classification, and launch-context coverage +- [x] No browser, heavy-governance, or queue family is introduced implicitly +- [x] Minimal validation commands are explicit in the plan +- [x] The active feature PR close-out entry remains `Guardrail` + +## Review Outcome + +- [x] Review outcome class: `keep` +- [x] Workflow outcome: `keep` +- [x] Next command readiness: implementation prep is ready once artifact analysis is clear + +## Notes + +- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and this checklist artifact. It does not claim that runtime code or a promotion workflow already exists. +- The active slice stops before any target mutation, any queued execution, any persisted draft or compare snapshot, and any broader mapping automation. +- No new globally searchable resource is introduced, no new asset registration is expected, and deployment behavior remains unchanged unless a later implementation explicitly adds assets. \ No newline at end of file diff --git a/specs/043-cross-tenant-compare-and-promotion/plan.md b/specs/043-cross-tenant-compare-and-promotion/plan.md index 0ff77aea..2a1c7982 100644 --- a/specs/043-cross-tenant-compare-and-promotion/plan.md +++ b/specs/043-cross-tenant-compare-and-promotion/plan.md @@ -1,24 +1,210 @@ -# Implementation Plan: Cross-tenant Compare and Promotion +# Implementation Plan: Cross-Tenant Compare Preview and Promotion Preflight -**Date**: 2026-01-07 -**Spec**: `specs/043-cross-tenant-compare-and-promotion/spec.md` +**Branch**: `043-cross-tenant-compare-and-promotion` | **Date**: 2026-04-27 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from [spec.md](spec.md) ## Summary -Introduce read-only cross-tenant comparison views; optionally add promotion with strong safety gates. +Refresh Spec 043 into a narrow, implementation-ready workflow that adds one canonical workspace-context compare page under `/admin`, one reusable compare preview builder, and one read-only promotion preflight action. The slice reuses existing baseline compare subject identity, portfolio-triage context continuity, capability resolvers, and workspace audit logging. It deliberately stops before actual promotion execution, queueing, or persisted promotion drafts. -## Dependencies +Filament remains on Livewire v4, no panel-provider registration changes are required (`bootstrap/providers.php` remains the authoritative provider registration location), no globally searchable compare resource is added, and no new panel asset bundle is expected. -- Inventory core + UI (Specs 040–041) -- Strong authorization model for multi-tenant access +## Technical Context -## Deliverables +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing baseline compare services, portfolio-triage seams, audit services, and capability resolvers +**Storage**: PostgreSQL via existing inventory, policy-version, and audit tables; no new compare or promotion table +**Testing**: Pest v4 `Unit` and `Feature` coverage only +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`) +**Project Type**: Web application (Laravel monolith with Filament pages) +**Performance Goals**: compare preview and promotion preflight stay synchronous and derived from existing persisted truth; no background execution path in v1 +**Constraints**: no target mutation, no `OperationRun`, no queue, no new persisted draft, no cross-workspace compare, no raw payload view by default +**Scale/Scope**: 2 tenant selectors, 1 canonical compare page, 1 preflight action, 1 launch/return continuity path, focused reuse of existing compare builders -- Tenant selection + comparison view -- Safe diff output and export -- (Optional) gated promotion workflow +## UI / Surface Guardrail Plan -## Risks +- **Guardrail scope**: one new canonical compare page plus one launch action from existing tenant-registry/portfolio context +- **Native vs custom classification summary**: native Filament page with shared compare/audit/navigation primitives +- **Shared-family relevance**: canonical admin pages, compare drill-down patterns, launch actions, audit-backed modal/action copy +- **State layers in scope**: page, query state +- **Audience modes in scope**: operator-MSP only +- **Decision/diagnostic/raw hierarchy plan**: decision-first compare summary, diagnostics second, raw evidence stays on existing tenant/baseline surfaces +- **Raw/support gating plan**: no new raw/support surface; keep payload proof behind existing pages +- **One-primary-action / duplicate-truth control**: the compare page keeps one dominant next action, `Generate promotion preflight`; drill-down and return actions stay secondary +- **Launch default**: the tenant-registry launch action prefills the launched tenant as `target tenant`; the operator chooses the source tenant explicitly +- **Handling modes by drift class or surface**: review-mandatory; any actual promotion execution or queue path is exception-required and out of scope +- **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 +- **Active feature PR close-out entry**: Guardrail -- Data leakage across tenants -- Over-scoping promotion beyond safe MVP +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: + - `App\Filament\Pages\BaselineCompareLanding` + - `App\Filament\Pages\BaselineCompareMatrix` + - `App\Filament\Resources\TenantResource` + - `App\Filament\Resources\TenantResource\Pages\ListTenants` + - `App\Services\Baselines\BaselineCompareService` + - `App\Support\Baselines\BaselineCompareMatrixBuilder` + - `App\Support\Baselines\Compare\CompareStrategyRegistry` + - `App\Services\PortfolioTriage\TenantTriageReviewService` + - `App\Services\Audit\WorkspaceAuditLogger` + - `App\Support\Audit\AuditActionId` + - `App\Support\Navigation\CanonicalNavigationContext` +- **Shared abstractions reused**: capability resolvers, baseline compare strategy selection, canonical navigation context, existing audit recorder/logger path, and tenant-registry return-state conventions +- **New abstraction introduced? why?**: one narrow compare preview builder and one narrow promotion preflight service, because no existing service accepts source+target tenant scope and computes promotion readiness without execution +- **Why the existing abstraction was sufficient or insufficient**: tenant-level baseline compare is sufficient for subject identity, evidence posture, and drill-down semantics, but insufficient for dual-tenant scope and promotion-readiness reasoning +- **Bounded deviation / spread control**: no local compare sidecars on tenant pages; future callers must route through the canonical compare page and its services + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no +- **Central contract reused**: `N/A` +- **Delegated UX behaviors**: `N/A` +- **Surface-owned behavior kept local**: compare preview and preflight remain synchronous and read-only +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes +- **Provider-owned seams**: Microsoft-first inventory subject identity and policy-type mapping remain inside existing baseline compare strategy selection and inventory data +- **Platform-core seams**: source/target tenant scope, compare preview contract, promotion preflight contract, operator-facing readiness vocabulary +- **Neutral platform terms / contracts preserved**: `source tenant`, `target tenant`, `governed subject`, `compare preview`, `promotion preflight`, and `blocked reason` +- **Retained provider-specific semantics and why**: existing policy-type and inventory semantics remain Microsoft-first because this repo still has one real provider domain; the compare page should not invent fake provider-neutral mapping logic above that seam +- **Bounded extraction or follow-up path**: follow-up-spec only if later provider domains become current-release truth + +## Constitution Check + +*GATE: Must pass before implementation preparation continues.* + +- Inventory-first: PASS. Compare preview and preflight derive from existing inventory and policy-version truth rather than a new compare snapshot. +- Read/write separation: PASS. This slice stays read-only; no write execution is introduced. +- Graph contract path: PASS. No new Graph endpoint or direct provider call is added. +- Deterministic capabilities: PASS. Reuse existing capability registries such as `Capabilities::TENANT_VIEW`, `Capabilities::WORKSPACE_BASELINES_VIEW`, `Capabilities::WORKSPACE_BASELINES_MANAGE`, and existing tenant sync/manage seams. +- Workspace and tenant isolation: PASS. The compare page must resolve workspace membership first and source/target entitlement second, with `404` for inaccessible tenants. +- RBAC-UX plane separation: PASS. This slice lives only in `/admin`; no `/system` or cross-plane route is introduced. +- Destructive action discipline: PASS by non-use. The slice contains no destructive action. +- Global search: PASS. No new Resource or Global Search result is introduced. +- OperationRun / Ops-UX: PASS by non-use. Actual promotion execution is deferred. +- Data minimization: PASS. The compare page summarizes derived readiness and blocks; raw payloads stay on existing tenant/baseline pages. +- Test governance: PASS. Proof stays in `Unit` plus `Feature`; no browser or heavy-governance expansion is planned. +- Proportionality / no premature abstraction: PASS. One preview builder and one preflight service are justified by the dual-tenant workflow; no new persistence or framework layer is added. +- Persisted truth: PASS. No new compare or promotion table. +- Behavioral state: PASS. Readiness and blocked reasons remain derived, not persisted. +- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing compare, navigation, and audit paths are extended rather than replaced. +- Provider boundary: PASS. Microsoft-shaped subject matching stays in existing strategy seams; the page contract stays platform-neutral. +- Filament/Laravel panel safety: PASS. Filament v5 remains on Livewire v4, no provider registration change beyond `bootstrap/providers.php`, and no new assets are planned. + +**Gate evaluation**: PASS. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Feature` for the compare page, launch context, auth, and audit; `Unit` for compare preview matching and promotion-preflight classification +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: feature tests prove the Filament page and launch path while unit tests keep preview/preflight rules cheap and isolated. Browser or heavy-governance coverage is not required for the first read-only slice. +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php` +- **Fixture / helper / factory / seed / context cost risks**: reuse existing inventory, baseline compare, tenant registry, and portfolio-triage fixtures; avoid browser setup, queue fixtures, or seeded promotion history +- **Expensive defaults or shared helper growth introduced?**: no +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: standard-native-filament +- **Closing validation and reviewer handoff**: rerun the six focused commands above and confirm the slice remains read-only, deny-as-not-found-safe, and grounded on existing compare + portfolio seams +- **Budget / baseline / trend follow-up**: none expected +- **Review-stop questions**: lane fit, hidden fixture growth, accidental write execution, accidental queue/runtime scope +- **Escalation path**: `document-in-feature` for contained lane drift, `reject-or-split` for any attempt to add execution scope +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: test upkeep remains feature-local; only actual promotion execution or multi-provider compare would warrant a separate follow-up spec + +## Project Structure + +### Documentation (this feature) + +```text +specs/043-cross-tenant-compare-and-promotion/ +├── checklists/ +│ └── requirements.md +├── spec.md +├── plan.md +└── tasks.md +``` + +This refresh intentionally limits itself to the core preparation package plus `checklists/requirements.md`. No additional research/data-model/contracts artifact is required to make the narrowed slice implementation-ready. + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/Pages/ +│ │ ├── BaselineCompareLanding.php +│ │ ├── BaselineCompareMatrix.php +│ │ └── [new canonical compare page] +│ ├── Filament/Resources/TenantResource.php +│ ├── Filament/Resources/TenantResource/Pages/ListTenants.php +│ ├── Models/ +│ │ ├── InventoryItem.php +│ │ └── PolicyVersion.php +│ ├── Services/Audit/ +│ │ └── WorkspaceAuditLogger.php +│ ├── Services/Baselines/ +│ │ └── BaselineCompareService.php +│ ├── Services/PortfolioTriage/ +│ │ └── TenantTriageReviewService.php +│ ├── Support/Audit/AuditActionId.php +│ ├── Support/Baselines/ +│ │ ├── BaselineCompareMatrixBuilder.php +│ │ └── Compare/CompareStrategyRegistry.php +│ └── Support/PortfolioCompare/ or Services/PortfolioCompare/ +└── tests/ + ├── Feature/PortfolioCompare/ + └── Unit/Support/PortfolioCompare/ +``` + +**Structure Decision**: keep implementation inside `apps/platform`, reuse existing compare and portfolio seams, and introduce at most one small `PortfolioCompare` support/service namespace for the new dual-tenant preview/preflight logic. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| New compare preview builder | dual-tenant compare needs one place to translate existing inventory/baseline truth into a canonical preview contract | page-local mapping would duplicate compare logic and drift from existing baseline compare seams | +| New promotion preflight service | readiness reasoning must stay read-only and auditable before any execution path exists | bolting readiness rules into the page would make later reuse and testing brittle | + +## Proportionality Review + +- **Current operator problem**: portfolio operators still lack one bounded surface that answers whether a target tenant can follow a source tenant. +- **Existing structure is insufficient because**: existing baseline compare is tenant-vs-reference, not tenant-vs-tenant, and portfolio triage does not compute promotion readiness. +- **Narrowest correct implementation**: one canonical page plus one preview builder and one preflight service, no new table, no execution path. +- **Ownership cost created**: maintain a small preview/preflight contract and a focused test family. +- **Alternative intentionally rejected**: actual promotion execution, persisted promotion drafts, and local compare sidecars were rejected as premature. +- **Release truth**: current-release gap, not speculative platform work. + +## Implementation Strategy + +### Suggested MVP Scope + +MVP = **US1 + US2 together**. A compare page without a promotion preflight leaves the core decision incomplete, and a preflight without a canonical compare page has no trustworthy operator context. + +### Incremental Delivery + +1. Reuse current compare, navigation, capability, and audit seams. +2. Deliver the canonical compare preview. +3. Add the read-only promotion preflight on top of the same page and services. +4. Add launch/return continuity from portfolio-triage and tenant-registry context. +5. Finish with narrow validation and formatting. + +### Team Strategy + +1. Settle the preview/preflight contracts first. +2. Parallelize unit tests for preview/preflight rules and feature tests for page/auth behavior. +3. Serialize merges around the canonical compare page and the shared `PortfolioCompare` service namespace so the page contract does not drift. diff --git a/specs/043-cross-tenant-compare-and-promotion/spec.md b/specs/043-cross-tenant-compare-and-promotion/spec.md index f7e416aa..3adb2752 100644 --- a/specs/043-cross-tenant-compare-and-promotion/spec.md +++ b/specs/043-cross-tenant-compare-and-promotion/spec.md @@ -1,59 +1,293 @@ -# Feature Specification: Cross-tenant Compare and Promotion +# Feature Specification: Cross-Tenant Compare Preview and Promotion Preflight -**Feature Branch**: `feat/043-cross-tenant-compare-and-promotion` +**Feature Branch**: `043-cross-tenant-compare-and-promotion` **Created**: 2026-01-07 -**Status**: Draft +**Updated**: 2026-04-27 +**Status**: Ready for implementation +**Input**: Refresh existing Spec 043 against `docs/product/spec-candidates.md`, `docs/product/implementation-ledger.md`, and `docs/product/roadmap.md` so the feature becomes a narrow, implementation-ready slice instead of a broad future ambition. -## Purpose +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* -Enable safe cross-tenant comparison of inventory and, optionally, controlled promotion workflows. +- **Problem**: TenantPilot now has portfolio visibility, triage continuity, and strong tenant-level baseline compare surfaces, but operators still lack one canonical workspace-level path to compare a source tenant to a target tenant and prepare a safe promotion decision. +- **Today's failure**: Operators can see that tenants differ, but they still reconstruct cross-tenant decisions manually across tenant registry, baseline compare, and tenant detail surfaces. Promotion remains a roadmap phrase, not a bounded product workflow. +- **User-visible improvement**: An authorized workspace operator can select a source and target tenant, review a structured compare preview of governed subjects, and generate a read-only promotion preflight that shows what is ready, blocked, or requires manual mapping before any write path exists. +- **Smallest enterprise-capable version**: One canonical `/admin` compare surface, one compare preview builder, one read-only promotion preflight action, deep links back to existing tenant and baseline compare surfaces, and bounded audit metadata for preflight entry points. No actual promotion execution ships in this slice. +- **Explicit non-goals**: No cutover, no write execution, no queue or `OperationRun`, no automatic target remapping of groups/tags/named locations, no cross-workspace compare, no customer-facing compare workspace, no provider marketplace, and no new persisted promotion draft entity. +- **Permanent complexity imported**: One canonical compare page, one narrow compare scope contract, one preview/preflight builder pair, one small audit metadata shape, and focused unit plus feature coverage. +- **Why now**: The implementation ledger explicitly identifies cross-tenant compare and promotion as one of the remaining real product gaps. It is the missing bridge between portfolio visibility and portfolio action. +- **Why not local**: A local compare action on one tenant page would duplicate entitlement, matching, audit, and promotion-readiness logic and would not create a reusable, canonical workspace workflow. +- **Approval class**: Workflow Compression +- **Red flags triggered**: New page + new compare/preflight service pair. Defense: the slice stays read-only, introduces no new table, reuses existing baseline compare and portfolio triage seams, and defers actual execution. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve -Comparison is read-only by default. Any write/promotion behavior must be explicitly gated, audited, and separately authorized. +## Spec Scope Fields *(mandatory)* -## User Scenarios & Testing +- **Scope**: canonical-view +- **Primary Routes**: + - new canonical admin compare page under `/admin` for cross-tenant compare preview and promotion preflight + - existing `/admin/tenants` portfolio/registry surfaces as launch and return context + - existing tenant detail and baseline compare pages as secondary drill-down targets rather than duplicated local detail panes +- **Data Ownership**: + - compare preview and promotion preflight remain derived from existing tenant-owned inventory, policy-version, and baseline-compare truth + - no new compare snapshot, promotion draft, or mapping table is introduced in v1 + - audit remains on the existing workspace audit log only +- **RBAC**: + - non-members or actors outside workspace scope receive `404` + - launch-action visibility requires established workspace context, `Capabilities::WORKSPACE_BASELINES_VIEW` on the workspace, and `Capabilities::TENANT_VIEW` on the launched tenant + - opening the compare page requires established workspace context and `Capabilities::WORKSPACE_BASELINES_VIEW` on the workspace + - loading preview data requires `Capabilities::TENANT_VIEW` on both source and target tenants + - executing promotion preflight requires the preview permissions plus `Capabilities::WORKSPACE_BASELINES_MANAGE` on the workspace + - for established members who can view compare but lack `Capabilities::WORKSPACE_BASELINES_MANAGE`, the preflight action remains visible but disabled with explicit permission help text; server-side attempts still return `403` + - the implementation must stay on existing capability registries instead of raw strings and must not introduce a new promotion capability family for this slice -### Scenario 1: Compare two tenants (read-only) -- Given the operator has access to Tenant A and Tenant B -- When they select two tenants and a set of policy types -- Then they can see differences in presence and key metadata +For canonical-view specs, the spec MUST define: -### Scenario 2: Compare with a stable reference -- Given a reference selection scope -- When the operator runs comparison -- Then results are stable and reproducible for that scope +- **Default filter behavior when tenant-context is active**: if launched from the tenant registry or portfolio-triage context, prefill the launched tenant as the `target tenant`, leave the `source tenant` intentionally user-selected, and preserve a return context token. +- **Explicit entitlement checks preventing cross-tenant leakage**: the compare surface must validate workspace membership first, then validate both source and target tenant entitlement before any preview data loads. Any inaccessible tenant input is treated as not found. -### Scenario 3: Promotion is explicitly gated (optional) -- Given promotion is enabled by policy -- When the operator initiates promotion -- Then the system requires explicit confirmation and records an audit event +## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)* -## Functional Requirements +- **Cross-cutting feature?**: yes +- **Interaction class(es)**: navigation entry points, compare/drill-down actions, audit metadata, and canonical workspace-context pages +- **Systems touched**: `ListTenants`, portfolio-triage state, `CanonicalNavigationContext`, `BaselineCompareLanding`, `BaselineCompareMatrix`, `BaselineCompareService`, `CompareStrategyRegistry`, `WorkspaceAuditLogger`, and `AuditActionId` +- **Existing pattern(s) to extend**: canonical `/admin` workspace-context pages, baseline compare preview patterns, portfolio-triage return-state patterns, and existing workspace audit metadata patterns +- **Shared contract / presenter / builder / renderer to reuse**: `CanonicalNavigationContext`, `ActionSurfaceDeclaration`, `BaselineCompareService`, `BaselineCompareMatrixBuilder`, `CompareStrategyRegistry`, `TenantTriageReviewService`, and `WorkspaceAuditLogger` +- **Why the existing shared path is sufficient or insufficient**: existing tenant-level baseline compare surfaces already solve stable subject matching, result framing, and drill-down semantics, but they are insufficient for cross-tenant compare because they do not accept dual-tenant scope or produce a promotion-readiness preflight. +- **Allowed deviation and why**: none. The new surface should extend current compare and navigation patterns, not invent a parallel compare UX family. +- **Consistency impact**: source tenant, target tenant, compare preview, promotion preflight, blocked reason, and ready/manual mapping language must stay consistent across page copy, modal copy, audit prose, and deep links. +- **Review focus**: reviewers must block new local compare widgets or tenant-specific preflight sidecars that bypass the canonical compare page or its shared preview/preflight services. -- FR1: Support selecting two tenants within authorized scope. -- FR2: Provide read-only diff views based on inventory metadata and stable identifiers. -- FR3: Provide exportable comparison results. -- FR4: If promotion is included: - - require explicit enablement - - require explicit confirmation per operation - - record audit logs - - support dry-run/preview +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* -## Non-Functional Requirements +- **Touches OperationRun start/completion/link UX?**: no +- **Shared OperationRun UX contract/layer reused**: `N/A` +- **Delegated start/completion UX behaviors**: `N/A` +- **Local surface-owned behavior that remains**: compare preview and promotion preflight stay synchronous and read-only in v1 +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception required?**: none -- NFR1: Enforce tenant isolation and least privilege across tenant selection and data access. -- NFR2: Comparison must not expose secrets or unsafe payload fields. +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: yes +- **Boundary classification**: mixed +- **Seams affected**: compare subject identity, compare strategy reuse, promotion preflight reason vocabulary, and operator-facing compare terminology +- **Neutral platform terms preserved or introduced**: `source tenant`, `target tenant`, `governed subject`, `compare preview`, `promotion preflight`, `mapping gap`, and `blocked reason` +- **Provider-specific semantics retained and why**: Microsoft-first policy-type and inventory semantics remain inside existing compare strategy and inventory seams because the repo currently has one real provider domain. They should not leak deeper into the page contract than necessary. +- **Why this does not deepen provider coupling accidentally**: the page and services stay anchored on existing compare registries and inventory identifiers instead of inventing Microsoft-specific page contracts or raw Graph payload handling. +- **Follow-up path**: future multi-provider compare remains a separate follow-up spec if it ever becomes current-release truth. + +## 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 | +|---|---|---|---|---|---|---| +| Canonical cross-tenant compare page | yes | Native Filament page plus shared compare primitives | compare preview, navigation, audit-backed preflight action | page, query state, compare summary, modal/action state | no | Reuses baseline compare language and drill-down patterns instead of a custom standalone shell | +| Tenant registry / portfolio launch action | yes | Native Filament action | navigation entry point, contextual launch | table state, query/deep-link state | no | Extends existing portfolio-triage return-state handling | +| Actual promotion execution surface | no | N/A | none | none | no | `N/A - explicitly deferred` | + +## 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 | +|---|---|---|---|---|---|---|---| +| Canonical cross-tenant compare page | Primary Decision Surface | Operator decides whether the target tenant is ready for promotion planning or still blocked by scope and mapping gaps | source/target summary, ready/blocked/manual counts, top blockers, and next action | tenant drill-down, baseline compare drill-down, subject-level diagnostics | Primary because it is the first canonical workspace place where cross-tenant action becomes decidable | Moves from portfolio triage into compare and preflight without manual reconstruction | Replaces cross-page mental diffing with one bounded decision surface | +| Tenant registry / portfolio launch action | Secondary Context | Operator chooses when to leave the tenant registry for compare | current tenant context and preserved return state | compare details live on the compare page | Secondary because it launches the decision surface rather than hosting it | Keeps portfolio review flow intact | Reduces repeated tenant re-selection and filter loss | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Canonical cross-tenant compare page | operator-MSP | source/target summary, compare counts, preflight readiness summary, top blocked reasons | subject-level mapping gaps and deep links to tenant-specific evidence | raw payloads remain on existing tenant/baseline pages, not this surface | `Generate promotion preflight` | raw JSON, provider IDs, and low-level evidence stay behind existing detail pages | compare page states the decision truth once; drill-down pages add proof rather than rephrasing the same blocker | +| Tenant registry / portfolio launch action | operator-MSP | current tenant context and compare launch intent | return-state token only | none | `Compare tenants` | any future write action remains absent | launch action does not duplicate compare summaries on the registry row | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Canonical cross-tenant compare page | Utility / Workspace Decision | Draft apply analysis | Generate promotion preflight or open drill-down evidence | explicit selectors plus focused compare/preflight panels | forbidden | drill-down links and secondary navigation stay below the summary/preflight sections | none in v1 | new canonical `/admin` compare route | same page with shareable query state | workspace context plus source/target tenant chips | Cross-tenant compare | whether the target is ready, blocked, or needs manual mapping | none | +| Tenant registry / portfolio launch action | List / Table / Launch Context | Launch context support | Open compare with current tenant prefilled | explicit action from tenant list or triage context | preserved existing row behavior | compare entry is a safe secondary action | none | `/admin/tenants` | compare route | current workspace and tenant | Tenant registry | why the action launches compare, not promotion | existing tenant registry action hierarchy remains valid | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Canonical cross-tenant compare page | Workspace operator / MSP operator | Decide whether a target tenant is ready for a later promotion workflow | Canonical decision page | Can this target tenant safely follow the selected source tenant for the chosen governed subjects? | source/target summary, compare counts, blocked reasons, ready/manual counts, and next action | subject-level mappings, stale evidence signals, and deep links to existing tenant compare/detail surfaces | compare state, readiness, mapping confidence, evidence freshness | TenantPilot only in v1 | Generate promotion preflight, open source tenant, open target tenant | none | +| Tenant registry / portfolio launch action | Workspace operator / MSP operator | Start compare from an existing portfolio review path | Registry action | Which tenant should I compare next without losing context? | current tenant identity and compare launch intent | preserved triage filters and return token | launch context only | none | Compare tenants | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: yes - one narrow compare preview builder and one narrow promotion preflight service +- **New enum/state/reason family?**: no new persisted state family; readiness and blocked reasons remain derived from compare/preflight results +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: operators can identify tenants that need attention but cannot reach a trustworthy cross-tenant decision without manual reconstruction. +- **Existing structure is insufficient because**: existing tenant-level baseline compare pages and portfolio triage state do not support dual-tenant scope or promotion-readiness reasoning. +- **Narrowest correct implementation**: derive compare preview and promotion preflight from existing inventory/baseline truth, keep the page canonical and read-only, and audit only the preflight entry points. +- **Ownership cost**: maintain one compare page, one preview builder, one preflight service, and a handful of focused tests. +- **Alternative intentionally rejected**: actual promotion execution and persisted draft plans were rejected because they would add write risk, queue semantics, and new truth before the compare/preflight workflow is proven. +- **Release truth**: current-release workflow gap, not future-release platform speculation + +### 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**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: unit coverage proves preview matching and promotion-preflight classification without Filament overhead, while focused feature coverage proves page rendering, launch context, audit, and `404`/`403` semantics on the canonical compare surface. +- **New or expanded test families**: one focused `PortfolioCompare` feature family and one focused `Unit/Support/PortfolioCompare` family +- **Fixture / helper cost impact**: moderate; reuse existing tenant, workspace, inventory, baseline compare, and portfolio-triage fixtures instead of adding browser setup or queue scaffolding +- **Heavy-family visibility / justification**: none; do not widen this slice into browser or heavy-governance lanes by default +- **Special surface test profile**: standard-native-filament +- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient for the page and launch actions; a small unit test set must prove preflight classification and no-write semantics +- **Reviewer handoff**: reviewers must confirm that the slice stays read-only, reuses baseline compare and portfolio seams, preserves deny-as-not-found semantics for inaccessible tenants, and does not smuggle in actual promotion execution +- **Budget / baseline / trend impact**: low increase in unit + feature only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php` + +## Scope Boundaries + +### In Scope + +- one canonical workspace-context compare page for source/target tenant selection +- read-only compare preview using stable governed-subject identity and existing compare strategy patterns +- one read-only promotion preflight action that classifies ready, blocked, and manual-mapping subjects +- workspace audit metadata for preflight entry points +- launch and return continuity from portfolio-triage/tenant-registry context +- deep links to existing tenant and baseline compare detail pages instead of duplicated proof surfaces + +### Non-Goals + +- actual promotion execution or target mutation +- queueing, retries, or `OperationRun` +- persisted compare snapshots or promotion draft tables +- automatic mapping writers for groups, scope tags, filters, named locations, or app references +- customer-facing review or compare surfaces +- cross-workspace compare +- multi-provider compare frameworks + +## Assumptions + +- existing inventory and baseline compare seams already provide enough stable subject identity to drive a first compare preview +- current portfolio-triage return-state patterns are sufficient for launch and back-navigation continuity +- a read-only preflight is valuable before any write path exists and can be audited without introducing a second persistence truth + +## Risks + +- some compare subjects may still need provider-specific mapping logic before they can produce a trustworthy readiness result +- target inventory freshness or missing evidence may block preflight more often than expected and needs explicit reasoning on the page +- a later implementation could try to add actual promotion execution inside this slice; that must be rejected as scope growth + +## Follow-up Candidates + +- Cross-tenant promotion execution with preview -> confirmation -> queued run -> verify +- Managed mapping workflows for named locations, assignments, groups, and filters +- Cross-tenant decision inbox integration after compare/preflight exists + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Compare two authorized tenants (Priority: P1) + +As a workspace operator, I want to compare one source tenant to one target tenant from a canonical workspace surface so I can see where governed subjects match, differ, or are missing without reconstructing the answer manually. + +**Why this priority**: This is the smallest valuable slice that turns portfolio visibility into a concrete operator decision surface. + +**Independent Test**: Open the compare page with two authorized tenants, choose governed-subject filters, and verify that the compare preview shows reproducible ready/different/missing results and drill-down links. + +**Acceptance Scenarios**: + +1. **Given** an operator has access to both selected tenants, **When** they open the compare page and run the preview, **Then** they see a structured compare summary grouped by governed-subject state rather than a raw payload diff. +2. **Given** the same source and target selection, **When** the operator reloads or shares the preview URL, **Then** the compare state is reproducible for the same scoped selection. +3. **Given** the operator selects the same tenant as both source and target, **When** they try to run the preview, **Then** the page rejects the selection as invalid and does not produce compare or preflight output. + +--- + +### User Story 2 - Generate a promotion preflight without writing (Priority: P1) + +As a workspace operator, I want a read-only promotion preflight that tells me what is ready, blocked, or needs manual mapping before any cross-tenant write path exists. + +**Why this priority**: Promotion language is not trustworthy until the product can explain why a target is or is not ready in a bounded, auditable way. + +**Independent Test**: From an authorized compare preview, trigger the preflight action and verify that the page shows readiness counts, blocked reasons, and manual-mapping requirements without mutating source or target tenants. + +**Acceptance Scenarios**: + +1. **Given** a compare preview contains subjects with stable identity and usable target conditions, **When** the operator generates a promotion preflight, **Then** those subjects appear as ready with a clear explanation. +2. **Given** some subjects are missing identifiers, stale, or blocked by target conditions, **When** the operator generates the preflight, **Then** those subjects appear as blocked or manual-mapping-required with explicit reasons. +3. **Given** the operator generates a preflight, **When** the action completes, **Then** no target mutation, queued run, or provider write occurs. +4. **Given** the operator can view compare but lacks `WORKSPACE_BASELINES_MANAGE`, **When** they reach the compare page, **Then** the preflight action is visibly disabled with permission guidance and any forced request is rejected server-side. + +--- + +### User Story 3 - Launch compare from portfolio context without losing return state (Priority: P2) + +As a workspace operator, I want to enter compare from the tenant registry or portfolio-triage context and return without losing my working filters so compare becomes part of the portfolio workflow instead of a detached utility. + +**Why this priority**: The workflow is much less useful if compare starts from scratch and breaks the operator's portfolio-review context. + +**Independent Test**: Launch compare from the tenant registry with active triage filters, verify one tenant is prefilled, and verify the return path restores the prior registry state. + +**Acceptance Scenarios**: + +1. **Given** the tenant registry has active portfolio-triage filters, **When** the operator launches compare from a tenant row or contextual action, **Then** the compare page preserves a return token and prefills the launched tenant as the `target tenant`. +2. **Given** the operator returns from compare, **When** the registry reloads, **Then** the prior triage filters are restored. + +### Edge Cases + +- source and target tenant are the same tenant: reject the selection as invalid input and do not compute preview or preflight +- source and target tenants belong to different workspaces +- one selected tenant is no longer visible or never belonged to the actor's scope +- compare subjects have ambiguous identity or duplicate matches +- target evidence is stale or missing, making readiness impossible to prove + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR1**: The feature MUST provide one canonical workspace-context compare surface for selecting source and target tenants. +- **FR2**: The feature MUST enforce workspace membership and source/target tenant entitlement before loading compare data; inaccessible tenants resolve as `404`. +- **FR3**: The compare preview MUST use stable governed-subject identity and existing inventory/baseline compare seams rather than raw JSON diffing. +- **FR4**: The compare preview MUST stay read-only and MUST deep-link to existing tenant or baseline detail surfaces for proof instead of duplicating raw diagnostics locally. +- **FR5**: The feature MUST provide a read-only promotion preflight action that classifies subjects as ready, blocked, or manual-mapping-required. +- **FR6**: The preflight MUST NOT execute a target write, queue a run, or persist a promotion draft artifact. +- **FR7**: The preflight MUST explain blocked and manual states with explicit operator-readable reasons. +- **FR8**: The feature MUST reuse existing capability registries with this exact split: page access = `WORKSPACE_BASELINES_VIEW`, preview data = `TENANT_VIEW` on both tenants, preflight execution = `WORKSPACE_BASELINES_MANAGE`. +- **FR9**: The feature MUST preserve launch and return continuity from the tenant registry / portfolio-triage path. +- **FR10**: The feature MUST record bounded workspace audit metadata for promotion-preflight entry points only. +- **FR11**: The compare page MUST reject same-tenant selection before preview or preflight runs. + +### Non-Functional Requirements + +- **NFR1**: The feature MUST preserve workspace and tenant isolation and MUST NOT leak source or target hints to unauthorized actors. +- **NFR2**: The compare page MUST remain operator-first, decision-first, and must not expose raw payloads by default. +- **NFR3**: The implementation MUST remain Filament-native on Livewire v4 and must not introduce a second compare shell or custom status framework. +- **NFR4**: The slice MUST not introduce new assets or new globally searchable resources. ## Success Criteria -- SC1: Operators can identify which tenant differs for a given policy type in under 2 minutes. -- SC2: Read-only comparisons are reproducible when run again with the same scope. - -## Out of Scope - -- Bulk remediation without preview/confirmation. +- **SC1**: An authorized operator can produce a cross-tenant compare preview from one canonical page without switching across multiple tenant detail surfaces. +- **SC2**: The same source, target, and filter selection produces reproducible compare output. +- **SC3**: A promotion preflight clearly separates ready, blocked, and manual subjects without performing any write. +- **SC4**: Unauthorized source/target combinations remain deny-as-not-found. +- **SC5**: View-only members can inspect compare results but cannot execute preflight, and the UI makes that boundary explicit. ## Related Specs - Program: `specs/039-inventory-program/spec.md` - Core: `specs/040-inventory-core/spec.md` +- UI: `specs/041-inventory-ui/spec.md` - Drift: `specs/044-drift-mvp/spec.md` +- Foundation follow-up context: `docs/product/spec-candidates.md` (`Cross-Tenant Compare and Promotion v1`) diff --git a/specs/043-cross-tenant-compare-and-promotion/tasks.md b/specs/043-cross-tenant-compare-and-promotion/tasks.md index bed50126..f3176f9d 100644 --- a/specs/043-cross-tenant-compare-and-promotion/tasks.md +++ b/specs/043-cross-tenant-compare-and-promotion/tasks.md @@ -1,7 +1,190 @@ -# Tasks: Cross-tenant Compare and Promotion +--- -- [ ] T001 Define authorized tenant selection rules -- [ ] T002 Read-only compare UI and diff rules -- [ ] T003 Export capability for comparison results -- [ ] T004 If enabled: promotion workflow with preview + confirm + audit -- [ ] T005 Tests: tenant isolation, authorization, reproducibility +description: "Task list for Cross-Tenant Compare Preview and Promotion Preflight" + +--- + +# Tasks: Cross-Tenant Compare Preview and Promotion Preflight + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` (required) + +**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage by default for this first read-only slice. +**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Promotion remains preflight-only. +**RBAC**: Existing workspace and tenant membership semantics remain authoritative. Non-members or actors lacking source or target tenant entitlement receive `404`; members who reach the canonical compare surface but lack the required capability receive `403`. Page access uses `Capabilities::WORKSPACE_BASELINES_VIEW`, preview data uses `Capabilities::TENANT_VIEW` on both tenants, and preflight execution adds `Capabilities::WORKSPACE_BASELINES_MANAGE`. +**Provider Boundary**: The page contract stays platform-neutral (`source tenant`, `target tenant`, `governed subject`, `promotion preflight`) while reusing Microsoft-first inventory and baseline compare seams under the hood. +**Organization**: Tasks are grouped by user story so compare preview, promotion preflight, and portfolio launch continuity remain independently testable once the shared contracts exist. + +## Test Governance Checklist + +- [ ] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior. +- [ ] New or changed tests stay in `apps/platform/tests/Unit/Support/PortfolioCompare/` and `apps/platform/tests/Feature/PortfolioCompare/` only. +- [ ] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or seeded promotion history. +- [ ] Planned validation commands cover compare preview, promotion preflight, launch continuity, audit, and authorization without widening scope. +- [ ] The declared surface test profile remains `standard-native-filament` because the slice adds one canonical page and one launch action on existing surfaces. +- [ ] Any deferred execution, mapping automation, or multi-provider follow-up resolves as `document-in-feature` or `follow-up-spec`, not hidden scope growth. + +## Phase 1: Setup (Shared Context) + +**Purpose**: Confirm the narrowed slice, the reusable compare seams, and the reviewer stop conditions before implementation begins. + +- [ ] T001 Review the narrowed compare-preview and promotion-preflight slice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` together with the current candidate and ledger references. +- [ ] T002 [P] Confirm the compare and subject-identity seams that this slice must reuse in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Services/Baselines/BaselineCompareService.php`, `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`, `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php`, `apps/platform/app/Models/InventoryItem.php`, and `apps/platform/app/Models/PolicyVersion.php`. +- [ ] T003 [P] Confirm the portfolio launch, authorization, and audit seams that this slice must reuse in `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`, `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Services/Auth/CapabilityResolver.php`, and `apps/platform/app/Services/Auth/WorkspaceCapabilityResolver.php`. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the shared compare scope and promotion-preflight primitives that every user story depends on. + +**Critical**: No user-story work should begin until this phase is complete. + +- [ ] T004 [P] Define the minimal compare-page input/output shape inside `apps/platform/app/Support/PortfolioCompare/` or `apps/platform/app/Services/PortfolioCompare/` for source tenant, target tenant, governed-subject filters, and preflight output without adding a wider DTO or resolver framework. +- [ ] T005 [P] Implement source-plus-target entitlement checks inside the canonical compare page and shared preview/preflight services using the existing capability resolvers so workspace membership, source entitlement, target entitlement, and capability denial all follow existing `404`/`403` semantics. +- [ ] T006 Implement the compare preview builder so it reuses stable governed-subject identity from existing inventory and baseline compare seams and produces a canonical preview summary without storing new compare truth. +- [ ] T007 Implement the promotion-preflight service so it classifies governed subjects as ready, blocked, or manual-mapping-required and explicitly performs no target mutation, queue dispatch, or `OperationRun` creation. +- [ ] T008 [P] Add bounded preflight audit action IDs and metadata shaping in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` for promotion-preflight entry points only. + +**Checkpoint**: Shared compare scope, entitlement resolution, preview building, preflight classification, and audit metadata exist; user stories can proceed independently. + +--- + +## Phase 3: User Story 1 - Compare Two Authorized Tenants (Priority: P1) MVP + +**Goal**: Give an authorized workspace operator one canonical compare page that shows a reproducible source-vs-target preview without cross-page reconstruction. + +**Independent Test**: Open the compare page with two authorized tenants, apply governed-subject filters, and verify that the preview shows match/difference/missing states plus drill-down links. + +### Tests for User Story 1 + +- [ ] T009 [P] [US1] Add feature coverage for rendering the canonical compare page, selecting source and target tenants, rejecting same-tenant selection, and showing one default-visible compare summary with no duplicate decision truth or raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`. +- [ ] T010 [P] [US1] Add feature coverage for `404` vs `403` semantics across source/target entitlement, workspace `WORKSPACE_BASELINES_VIEW`, tenant `TENANT_VIEW`, visible-disabled preflight UX for members lacking `WORKSPACE_BASELINES_MANAGE`, and server-side denial of forced preflight requests in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`. +- [ ] T011 [P] [US1] Add unit coverage for compare preview subject matching and reproducible summary output in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php`. + +### Implementation for User Story 1 + +- [ ] T012 [US1] Add the canonical compare page under `apps/platform/app/Filament/Pages/` with source/target selectors, governed-subject filters, shareable query state, and compare preview summary built from the shared preview builder. +- [ ] T013 [US1] Reuse existing baseline compare and inventory seams so the compare page deep-links to tenant-level proof surfaces instead of duplicating raw diagnostics. +- [ ] T014 [US1] Keep page copy, chips, and summary wording aligned to `source tenant`, `target tenant`, `governed subject`, and `compare preview` rather than Microsoft-first or execution-first vocabulary. + +**Checkpoint**: User Story 1 is independently functional when the canonical page produces a reproducible compare preview for two authorized tenants. + +--- + +## Phase 4: User Story 2 - Generate a Read-Only Promotion Preflight (Priority: P1) + +**Goal**: Let the operator ask whether the chosen target is ready for a later promotion workflow without performing any write. + +**Independent Test**: From an authorized compare preview, trigger the preflight action and verify that the page shows ready, blocked, and manual-mapping-required groups without mutating target data. + +### Tests for User Story 2 + +- [ ] T015 [P] [US2] Add unit coverage for preflight classification across ready, blocked, manual-mapping, stale-evidence, and missing-identifier cases in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`. +- [ ] T016 [P] [US2] Add feature coverage for the compare page's `Generate promotion preflight` action, visible-disabled manage-denial UX, one dominant next action, visible readiness summary, and no default-visible raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`. +- [ ] T017 [P] [US2] Add feature coverage for preflight audit metadata and explicit no-write semantics in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`. + +### Implementation for User Story 2 + +- [ ] T018 [US2] Add the read-only `Generate promotion preflight` action to the canonical compare page, keeping it distinct from any future execution action and free of queue/runtime side effects. +- [ ] T019 [US2] Render a promotion-preflight summary that groups governed subjects into ready, blocked, and manual-mapping-required buckets with explicit operator-readable reasons. +- [ ] T020 [US2] Route preflight entry-point audit through the existing workspace audit pipeline with source tenant, target tenant, subject counts, and blocked-reason metadata only. + +**Checkpoint**: User Story 2 is independently functional when the operator can generate an audited, read-only readiness decision from the compare page. + +--- + +## Phase 5: User Story 3 - Launch Compare from Portfolio Context Without Losing State (Priority: P2) + +**Goal**: Make compare part of the portfolio workflow by preserving the launch tenant and return state from the tenant registry / portfolio-triage path. + +**Independent Test**: Launch compare from the tenant registry with active triage filters, verify the launched tenant is prefilled, and verify the return path restores the prior registry state. + +### Tests for User Story 3 + +- [ ] T021 [P] [US3] Add feature coverage for compare launch and return continuity from the tenant registry / portfolio-triage path in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`. +- [ ] T022 [P] [US3] Extend authorization coverage so launch actions only appear or resolve when the current actor is entitled to the launched tenant and the compare surface in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`. + +### Implementation for User Story 3 + +- [ ] T023 [US3] Add a bounded launch action from `apps/platform/app/Filament/Resources/TenantResource.php` or `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` that opens the canonical compare page with the current tenant prefilled as the `target tenant`. +- [ ] T024 [US3] Preserve and restore portfolio-triage return state using the existing navigation-context pattern rather than a page-local custom token format. + +**Checkpoint**: User Story 3 is independently functional when compare can be launched from portfolio context and the operator can return without losing triage filters. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finish narrow validation and reviewer close-out without widening scope. + +- [ ] T025 [P] Run the focused unit validation commands for `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php` and `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`. +- [ ] T026 [P] Run the focused feature validation commands for `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`, and `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`. +- [ ] T027 Run dirty-only formatting for touched platform files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. +- [ ] T028 [P] Add or update the checklist/reviewer guard confirming that this slice introduces no new asset registration and no globally searchable resource. +- [ ] T029 Record TEST-GOV-001 close-out and any `document-in-feature` or `follow-up-spec` deferrals for actual execution, mapping automation, or multi-provider compare in the active feature PR or implementation notes. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: no dependencies; start immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories. +- **Phase 3 (US1)**: depends on Phase 2 and establishes the canonical compare truth. +- **Phase 4 (US2)**: depends on Phase 2 and should ship with US1 because compare without readiness reasoning leaves promotion language vague. +- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 because the canonical compare page must exist before launch continuity can target it. +- **Phase 6 (Polish)**: depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: independently testable after Phase 2 and forms the MVP decision surface. +- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 for a complete P1 slice. +- **US3 (P2)**: independently testable after Phase 2 and improves portfolio workflow continuity once the canonical page exists. + +### Within Each User Story + +- Write the listed Pest coverage first and make it fail for the intended behavior gap. +- Settle the shared preview/preflight service contract before adding or widening page wiring. +- Re-run the narrowest affected validation command after each story checkpoint before moving to the next story. + +--- + +## Parallel Execution Examples + +### User Story 1 + +- T009, T010, and T011 can run in parallel before runtime edits begin. +- After the preview contract settles, T012 and T013 can proceed in parallel because page wiring and compare-service reuse touch different seams; T014 should follow both. + +### User Story 2 + +- T015, T016, and T017 can run in parallel because they cover separate unit, page, and audit concerns. +- After T018 settles the action shape, T019 and T020 can proceed in parallel because UI rendering and audit metadata touch different seams. + +### User Story 3 + +- T021 and T022 can run in parallel before implementation starts. +- T023 should land before T024 so return-state handling can target the final launch route. + +--- + +## Implementation Strategy + +### Suggested MVP Scope + +- MVP = **US1 + US2 together**. The feature is only product-complete when the operator can compare two tenants and immediately ask whether that comparison is promotion-ready. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 and US2 together. +3. Add US3 launch and return continuity. +4. Finish with narrow validation and formatting in Phase 6. + +### Team Strategy + +1. Finish the preview/preflight contracts together before splitting page work. +2. Parallelize unit and feature test authoring inside each story first. +3. Serialize merges around the canonical compare page and shared `PortfolioCompare` service namespace so the workflow language stays coherent. diff --git a/specs/248-private-ai-policy-foundation/checklists/requirements.md b/specs/248-private-ai-policy-foundation/checklists/requirements.md new file mode 100644 index 00000000..6e0f025f --- /dev/null +++ b/specs/248-private-ai-policy-foundation/checklists/requirements.md @@ -0,0 +1,57 @@ +# Specification Quality Checklist: Private AI Execution & Policy Foundation + +**Purpose**: Validate full preparation-package completeness and implementation readiness before the feature moves into the implementation loop +**Created**: 2026-04-27 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] Business value and operator outcomes stay explicit +- [x] The first slice is bounded to one governed decision boundary, two approved internal-only use cases, one workspace AI policy section, and one reused operational control +- [x] Runtime-governance sections are present for an implementation-ready package, not treated as docs-only +- [x] All mandatory sections are completed + +## Requirement Completeness + +- [x] No `[NEEDS CLARIFICATION]` markers remain +- [x] Requirements are testable and unambiguous +- [x] Acceptance scenarios are defined for workspace policy, governed allow-or-block decisions, and central pause/resume handling +- [x] Edge cases are identified, including missing workspace context, unregistered use cases, blocked data classes, and active `ai.execution` control +- [x] Scope is clearly bounded away from customer-facing AI, external public-provider execution, queue or `OperationRun` work, and prompt or result persistence +- [x] Dependencies, assumptions, risks, and follow-up candidates are identified + +## Feature Readiness + +- [x] The first slice is small enough for a bounded implementation loop +- [x] Concrete repo surfaces are named for workspace settings, system ops controls, audit reuse, and the new in-process AI support namespace +- [x] Foundational work stays preparation-only and does not imply model runtime, customer UI, or a new AI table or result store +- [x] The tasks are ordered, testable, and grouped by user story +- [x] No unresolved product question blocks `/speckit.implement` once artifact analysis passes + +## Governance Readiness + +- [x] Workspace-owned AI policy truth is explicitly kept in existing settings persistence with no new AI table or result ledger +- [x] The approved-use-case catalog remains locked to two internal-only consumers and keeps provider vocabulary vendor-neutral +- [x] The package explicitly forbids customer-facing AI, external public-provider execution, and queue or `OperationRun` semantics in v1 +- [x] Existing workspace and platform authorization paths remain authoritative, with confirmation-protected `Pause AI execution` and `Resume AI execution` as the only destructive-like mutations in scope +- [x] Livewire v4 and Filament v5 compliance, unchanged provider registration in `bootstrap/providers.php`, no new global-search resource, and no asset-strategy changes are explicit in the package + +## Test Governance Review + +- [x] Lane fit stays in focused unit plus feature validation with one architecture guard only +- [x] Fixture and helper growth stays local to AI support, workspace settings, operational controls, and guard coverage +- [x] No browser, heavy-governance, queue, or provider-emulator family is introduced implicitly +- [x] Minimal validation commands are explicit in the plan and quickstart +- [x] The active feature PR close-out entry remains `Guardrail` + +## Review Outcome + +- [x] Review outcome class: `keep` +- [x] Workflow outcome: `keep` +- [x] Next command readiness: `/speckit.implement` after artifact analysis is clear + +## Notes + +- This checklist validates the preparation package only: `spec.md`, `plan.md`, supporting artifacts, and `tasks.md`. It does not claim that application code or an AI execution runtime already exists. +- The active slice stops before customer-facing AI, external-public provider execution, queue or `OperationRun` orchestration, prompt or result persistence, and any broader provider marketplace or budgeting work. +- Provider registration remains unchanged in `bootstrap/providers.php`, no new global-search resource is introduced, and no new asset strategy is needed for this package. \ No newline at end of file diff --git a/specs/248-private-ai-policy-foundation/contracts/private-ai-governance.openapi.yaml b/specs/248-private-ai-policy-foundation/contracts/private-ai-governance.openapi.yaml new file mode 100644 index 00000000..80797c7c --- /dev/null +++ b/specs/248-private-ai-policy-foundation/contracts/private-ai-governance.openapi.yaml @@ -0,0 +1,277 @@ +openapi: 3.0.3 +info: + title: TenantPilot AI Governance Foundation (Conceptual) + version: 0.1.0 + description: | + Conceptual contract for the existing workspace settings page, the existing + system operational-controls page, and the in-process governed AI decision + schema planned by Spec 248. + + NOTE: The settings and controls actions are implemented as existing Filament + (Livewire) pages/actions. No new customer-facing AI route or external + provider execution endpoint is introduced in v1. +servers: + - url: / +paths: + /admin/settings/workspace: + get: + summary: View workspace settings page + description: | + Existing singleton workspace settings route. + The AI policy section is planned to render on this page without adding a + second AI admin surface. + responses: + '200': + description: Workspace settings page rendered + content: + text/html: + schema: + type: string + '404': + description: Not found (wrong workspace or non-member) + '403': + description: Forbidden (member without view capability) + + /admin/settings/workspace/ai-policy: + post: + summary: Save workspace AI policy + description: | + Logical action on the existing Filament workspace settings page. + Non-members or wrong-workspace actors receive 404 semantics before any + policy detail is revealed. Members without + `workspace_settings.manage` receive 403 on mutation. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [policy_mode] + properties: + policy_mode: + $ref: '#/components/schemas/WorkspaceAiPolicyMode' + responses: + '204': + description: Policy saved + '403': + description: Forbidden (member lacks manage capability) + '404': + description: Not found (wrong workspace or non-member) + + /system/ops/controls: + get: + summary: View system operational controls page + description: | + Existing system control-center route. The AI execution control is added + here rather than on a new AI console. Wrong-plane or non-platform + actors keep deny-as-not-found semantics before any system control detail + is revealed. + responses: + '200': + description: Controls page rendered + content: + text/html: + schema: + type: string + '404': + description: Not found (wrong plane or non-platform actor) + '403': + description: Forbidden (platform actor lacks required system capability) + + /system/ops/controls/ai.execution/pause: + post: + summary: Pause AI execution globally + description: | + Logical control action on the existing system controls page. + Wrong-plane or non-platform actors receive 404 semantics before any + control detail is revealed. + Must require confirmation in the UI and enforce + `platform.access_system_panel` plus `platform.ops.controls.manage` + server-side. Spec 248 keeps `ai.execution` global-only in v1. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [reason_text] + properties: + reason_text: + type: string + expires_at: + type: string + format: date-time + nullable: true + responses: + '204': + description: Control activated + '404': + description: Not found (wrong plane or non-platform actor) + '403': + description: Forbidden (platform actor lacks required control capability) + + /system/ops/controls/ai.execution/resume: + post: + summary: Resume AI execution globally + description: | + Logical control action on the existing system controls page. + Wrong-plane or non-platform actors receive 404 semantics before any + control detail is revealed. + Removes an active `ai.execution` pause using the existing control-center + confirmation and audit flow. Spec 248 keeps `ai.execution` + global-only in v1. + responses: + '204': + description: Control resumed + '404': + description: Not found (wrong plane or non-platform actor) + '403': + description: Forbidden (platform actor lacks required control capability) + +components: + schemas: + WorkspaceAiPolicyMode: + type: string + enum: [disabled, private_only] + + ProviderClass: + type: string + enum: [local_private, external_public] + + AiDataClassification: + type: string + enum: + - product_knowledge + - operational_metadata + - redacted_support_summary + - personal_data + - customer_confidential + - raw_provider_payload + + ApprovedAiUseCaseKey: + type: string + enum: + - product_knowledge.answer_draft + - support_diagnostics.summary_draft + + GovernedAiExecutionRequest: + type: object + description: | + In-process service contract, not a public HTTP endpoint in v1. + This is the preflight envelope evaluated before any provider resolution + or model execution is attempted. The host surface must already have + resolved authorization and scope entitlement before this request is + constructed. + required: + - workspace_id + - actor_type + - actor_id + - use_case_key + - requested_provider_class + - data_classifications + - source_family + properties: + workspace_id: + type: integer + tenant_id: + type: integer + nullable: true + actor_type: + type: string + actor_id: + type: integer + use_case_key: + $ref: '#/components/schemas/ApprovedAiUseCaseKey' + requested_provider_class: + $ref: '#/components/schemas/ProviderClass' + data_classifications: + type: array + items: + $ref: '#/components/schemas/AiDataClassification' + source_family: + type: string + caller_surface: + type: string + nullable: true + context_fingerprint: + type: string + nullable: true + + GovernedAiExecutionDecision: + type: object + required: + - outcome + - reason_code + - workspace_ai_policy_mode + - use_case_key + - requested_provider_class + - data_classifications + - source_family + properties: + outcome: + type: string + enum: [allowed, blocked] + reason_code: + type: string + workspace_ai_policy_mode: + $ref: '#/components/schemas/WorkspaceAiPolicyMode' + matched_operational_control_scope: + type: string + enum: [global] + nullable: true + use_case_key: + $ref: '#/components/schemas/ApprovedAiUseCaseKey' + requested_provider_class: + $ref: '#/components/schemas/ProviderClass' + data_classifications: + type: array + items: + $ref: '#/components/schemas/AiDataClassification' + source_family: + type: string + audit_action: + type: string + audit_metadata: + $ref: '#/components/schemas/AiDecisionAuditMetadata' + + AiDecisionAuditMetadata: + type: object + required: + - use_case_key + - decision_outcome + - decision_reason + - workspace_ai_policy_mode + - requested_provider_class + - data_classifications + - source_family + - workspace_id + properties: + use_case_key: + $ref: '#/components/schemas/ApprovedAiUseCaseKey' + decision_outcome: + type: string + enum: [allowed, blocked] + decision_reason: + type: string + workspace_ai_policy_mode: + $ref: '#/components/schemas/WorkspaceAiPolicyMode' + requested_provider_class: + $ref: '#/components/schemas/ProviderClass' + data_classifications: + type: array + items: + $ref: '#/components/schemas/AiDataClassification' + source_family: + type: string + workspace_id: + type: integer + tenant_id: + type: integer + nullable: true + context_fingerprint: + type: string + nullable: true + matched_operational_control_scope: + type: string + enum: [global] + nullable: true \ No newline at end of file diff --git a/specs/248-private-ai-policy-foundation/data-model.md b/specs/248-private-ai-policy-foundation/data-model.md new file mode 100644 index 00000000..a304eb72 --- /dev/null +++ b/specs/248-private-ai-policy-foundation/data-model.md @@ -0,0 +1,209 @@ +# Data Model — Private AI Execution & Policy Foundation + +**Spec**: [spec.md](spec.md) + +No new persistent tables or AI artifact stores are required for v1. The feature reuses existing workspace settings, operational controls, and audit logs. New AI-specific structures are code-owned or request-scoped. + +## Persisted Truth Reused + +### Workspace AI Policy (`workspace_settings` carrier) + +**Purpose**: Workspace-owned policy truth that determines whether AI is disabled entirely or limited to approved private-only use cases. + +**Persisted carrier**: existing `workspace_settings` row via `WorkspaceSetting` + +**Planned definition**: +- `domain`: `ai` +- `key`: `policy_mode` +- `type`: `string` +- `system_default`: `disabled` +- `allowed values`: `disabled`, `private_only` +- `scope`: workspace only; no tenant override in v1 + +**Validation rules**: +- required +- string +- `in:disabled,private_only` + +**Authorization**: +- view: existing `workspace_settings.view` +- mutation: existing `workspace_settings.manage` + +**Audit strategy**: +- reuse `workspace_setting.updated` and `workspace_setting.reset` +- include AI-specific metadata in the existing workspace-settings audit context + +**State transitions**: +- `disabled` -> `private_only` +- `private_only` -> `disabled` + +### AI Execution Control (`operational_control_activations` carrier) + +**Purpose**: Platform-owned runtime stop for new AI execution attempts. + +**Persisted carrier**: existing `OperationalControlActivation` + +**Planned definition**: +- `control_key`: `ai.execution` +- `label`: `AI execution` +- `supported_scopes`: `global` +- `affected_surfaces`: governed AI decision callers only + +**Behavior**: +- a matching active control blocks new AI execution decisions before provider resolution +- global pause is the required v1 incident path +- workspace-specific pause or tenant-specific pause is out of scope for v1 and remains a follow-up concern if future incident handling genuinely requires it + +**State transitions**: +- `enabled` -> `paused` +- `paused` -> `enabled` + +### AI Decision Audit (`audit_logs` carrier) + +**Purpose**: Stable record of governed AI allow/block evaluations without storing raw prompt or output content. + +**Persisted carrier**: existing `audit_logs` rows through `WorkspaceAuditLogger` / `AuditRecorder` + +**Planned action strategy**: +- reuse existing workspace-setting actions for policy mutation +- add one bounded AI decision action ID, e.g. `ai_execution.decision_evaluated`, for governed decision evaluations + +**Planned metadata**: +- `use_case_key` +- `decision_outcome` (`allowed` or `blocked`) +- `decision_reason` +- `workspace_ai_policy_mode` +- `requested_provider_class` +- `data_classifications` +- `source_family` +- `workspace_id` +- optional `tenant_id` +- optional `context_fingerprint` +- optional `matched_operational_control_scope` + +**Explicit exclusions**: +- raw prompt text +- raw source payloads +- raw provider payloads +- full model output text + +## Code-Owned Truth + +### Approved AI Use Case Definition + +**Purpose**: Code-owned allowlist entry that defines one approved AI purpose and its trust constraints. + +**Fields**: +- `key` +- `future_consumer` +- `visibility` +- `allowed_provider_classes` +- `allowed_data_classifications` +- `source_family` +- `tenant_context_permitted` + +**v1 catalog is locked to exactly two entries**: + +| Key | Future Consumer | Visibility | Allowed Provider Classes | Allowed Data Classifications | Source Family | Tenant Context Permitted | +|---|---|---|---|---|---|---| +| `product_knowledge.answer_draft` | `ContextualHelpResolver` and related code-owned knowledge sources | `internal_only_draft` | `local_private` | `product_knowledge`, `operational_metadata` | `product_knowledge` | no | +| `support_diagnostics.summary_draft` | redacted summary derived from `SupportDiagnosticBundleBuilder` | `internal_only_draft` | `local_private` | `redacted_support_summary` | `support_diagnostics` | yes | + +**Validation rules**: +- key must be registered in the catalog +- no third use case may appear in v1 without a spec update +- `external_public` is never allowed for these entries in v1 + +### Provider Class + +**Purpose**: Vendor-neutral trust boundary for AI routing decisions. + +**Allowed values**: +- `local_private` +- `external_public` + +**Behavioral consequence**: +- `external_public` is always blocked in v1 +- `local_private` may be allowed only when the use case and data classifications permit it + +### AI Data Classification + +**Purpose**: Declarative label that determines whether a data family may cross the governed AI boundary. + +**Values**: +- `product_knowledge` +- `operational_metadata` +- `redacted_support_summary` +- `personal_data` +- `customer_confidential` +- `raw_provider_payload` + +**Behavioral consequence**: +- `personal_data`, `customer_confidential`, and `raw_provider_payload` are always blocked in v1 +- allowed classifications vary by use case + +## Request-Scoped Contracts + +### AI Execution Request + +**Purpose**: In-process request envelope passed to the governed decision boundary before any provider resolution or model execution is attempted. + +**Fields**: +- `workspace_id` +- optional `tenant_id` +- `actor_type` +- `actor_id` +- `use_case_key` +- `requested_provider_class` +- `data_classifications` (list) +- `source_family` +- optional `caller_surface` +- optional `context_fingerprint` + +**Validation rules**: +- `workspace_id` is required +- `use_case_key` must be registered +- `requested_provider_class` must be declared by the registered use case +- every declared data classification must be allowed for the use case +- host-surface authorization must already be resolved before evaluation + +**Important v1 boundary**: +- the request is a preflight contract and does not need to carry raw prompt or payload text in v1 +- future runtime/provider work can extend around this envelope later, but not inside this spec + +### AI Execution Decision + +**Purpose**: Terminal allow/block result returned by the governed boundary. + +**Fields**: +- `outcome` (`allowed` or `blocked`) +- `reason_code` +- `workspace_ai_policy_mode` +- `matched_operational_control_scope` (nullable) +- `use_case_key` +- `requested_provider_class` +- `data_classifications` +- `source_family` +- `audit_action` +- `audit_metadata` + +**Behavioral consequence**: +- `blocked`: provider resolution must not occur +- `allowed`: returns an approved handoff envelope only; v1 still does not execute a provider call or create a persisted result + +## State Transitions Summary + +### Workspace AI Policy + +- `disabled` <-> `private_only` + +### Operational Control + +- `enabled` <-> `paused` + +### AI Execution Decision + +- `evaluating` -> `allowed` +- `evaluating` -> `blocked` + +There is no queued, running, retrying, completed, or persisted-result lifecycle in v1. \ No newline at end of file diff --git a/specs/248-private-ai-policy-foundation/plan.md b/specs/248-private-ai-policy-foundation/plan.md new file mode 100644 index 00000000..bbfa3df3 --- /dev/null +++ b/specs/248-private-ai-policy-foundation/plan.md @@ -0,0 +1,282 @@ +# Implementation Plan: Private AI Execution & Policy Foundation + +**Branch**: `248-private-ai-policy-foundation` | **Date**: 2026-04-27 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from [spec.md](spec.md) + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Introduce a narrow AI governance foundation inside the existing Laravel monolith by reusing the workspace settings page for workspace-owned AI posture, reusing the system operational-controls page for a global `ai.execution` stop, and adding one in-process governed AI decision boundary plus a code-owned allowlist for exactly two internal-only use cases. Host-surface authorization remains a precondition; the AI boundary begins only after caller-side entitlement has already succeeded. The first slice is a preflight allow/block contract with audit-ready metadata, not a customer-facing AI workflow and not a model-provider runtime. + +Filament v5 remains on Livewire v4, no panel-provider registration changes are needed (`bootstrap/providers.php` remains the authoritative registration location), no new globally searchable AI resource is introduced, and no new panel-only asset bundle is expected for v1. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing Settings/Audit/OperationalControls support services +**Storage**: PostgreSQL via existing `workspace_settings`, `operational_control_activations`, and `audit_logs` persistence; no new AI tables +**Testing**: Pest v4 (PHPUnit 12 runner), narrow unit + feature + architecture-guard coverage +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Laravel monolith in `apps/platform` running via Sail; admin `/admin` and platform `/system` panels +**Project Type**: Web application (Laravel monolith with Filament panels) +**Performance Goals**: decision evaluation remains synchronous and DB-only in v1; no outbound provider call or queue handoff is required to compute allow/block +**Constraints**: no direct external provider calls with tenant data; no `OperationRun`; no result or prompt persistence; reuse existing workspace settings and ops controls; keep `/admin` and `/system` auth planes separate; no new asset bundle or second AI admin surface +**Scale/Scope**: 2 approved use cases, 2 policy modes, 2 provider classes, 6 data classifications, 2 existing operator surfaces, 1 new governed in-process decision seam + +## UI / Surface Guardrail Plan + +> **Fill for operator-facing or guardrail-relevant workflow changes. Docs-only or template-only work may use concise `N/A`. Copy the spec classification forward; do not rename or expand it here.** + +- **Guardrail scope**: changed surfaces on the existing workspace settings and system operational-controls pages +- **Native vs custom classification summary**: native Filament +- **Shared-family relevance**: workspace settings, operational safety controls, audit/status copy +- **State layers in scope**: page +- **Audience modes in scope**: operator-MSP, operator-platform, support-platform +- **Decision/diagnostic/raw hierarchy plan**: decision-first; diagnostics remain secondary on the control history path; no support-raw surface is introduced in v1 +- **Raw/support gating plan**: collapsed; raw prompt, source, and provider payload detail are excluded from the slice entirely +- **One-primary-action / duplicate-truth control**: workspace settings keep `Save` as the single primary mutation action; the system controls card keeps `Pause AI execution` / `Resume AI execution`; workspace policy truth and runtime-stop truth stay on separate surfaces +- **Handling modes by drift class or surface**: review-mandatory; any extra AI page, direct `Run AI` action, or evidence viewer is exception-required +- **Repository-signal treatment**: review-mandatory now, future hard-stop candidate once the no-direct-provider guard exists +- **Special surface test profiles**: standard-native-filament +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none; v1 remains inside the two existing pages +- **Active feature PR close-out entry**: Guardrail + +## 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 +- **Systems touched**: `WorkspaceSettings`, `SettingsRegistry`, `SettingsResolver`, `SettingsWriter`, `Controls`, `OperationalControlCatalog`, `OperationalControlEvaluator`, `AuditActionId`, `AuditRecorder`, `WorkspaceAuditLogger`, `ContextualHelpResolver`, and `SupportDiagnosticBundleBuilder` +- **Shared abstractions reused**: existing workspace settings persistence + audit flow, existing operational-control evaluator/catalog, existing audit recorder/logger pipeline, existing product-knowledge resolver, and existing support-diagnostics bundle builder path +- **New abstraction introduced? why?**: one in-process governed AI decision boundary and one code-owned use-case catalog, because the current shared settings/ops/audit services do not own AI allow/block semantics +- **Why the existing abstraction was sufficient or insufficient**: settings, ops controls, and audit are already sufficient for persistence, emergency stop, and logging; they are insufficient for AI decision evaluation because the repo currently has no app-level AI seam at all +- **Bounded deviation / spread control**: none; future callers must depend on the new boundary rather than page-local AI helpers + +## OperationRun UX Impact + +> **Fill when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`. Docs-only or template-only work may use concise `N/A`.** + +- **Touches OperationRun start/completion/link UX?**: no +- **Central contract reused**: `N/A` +- **Delegated UX behaviors**: `N/A` +- **Surface-owned behavior kept local**: initiation remains on the existing settings and controls pages only; no queued start UX is introduced +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception path**: none + +## Provider Boundary & Portability Fit + +> **Fill when the feature touches shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth. Docs-only or template-only work may use concise `N/A`.** + +- **Shared provider/platform boundary touched?**: yes +- **Provider-owned seams**: none in v1; no vendor adapters, credentials, or model-selection UI are introduced +- **Platform-core seams**: AI use-case key, provider class, data classification, workspace AI policy, and governed decision contract +- **Neutral platform terms / contracts preserved**: `AI use case`, `provider class`, `data classification`, `source family`, `workspace AI policy`, and `execution decision` +- **Retained provider-specific semantics and why**: none; `local_private` and `external_public` are trust classes, not vendor names +- **Bounded extraction or follow-up path**: follow-up-spec for provider integration and usage governance; do not widen inside v1 + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first / snapshot truth: N/A. This slice adds no inventory or backup truth and does not change the Intune source-of-truth model. +- Read/write separation: PASS. Workspace policy writes stay on the existing settings flow, and pause/resume actions stay on the existing controls flow with confirmation + audit. +- Graph contract path: PASS. No Microsoft Graph contract or outbound provider call is introduced. +- Deterministic capabilities: PASS. Reuses `Capabilities::WORKSPACE_SETTINGS_VIEW`, `Capabilities::WORKSPACE_SETTINGS_MANAGE`, `PlatformCapabilities::ACCESS_SYSTEM_PANEL`, and `PlatformCapabilities::OPS_CONTROLS_MANAGE`; no raw capability strings are planned. +- Workspace isolation + tenant isolation: PASS. AI decision requests require a host surface that already resolved workspace context and optional tenant entitlement; the boundary does not become a cross-tenant shortcut. +- RBAC-UX plane separation: PASS. `/admin/settings/workspace` stays tenant-plane/workspace-scoped, `/system/ops/controls` stays platform-scoped, and wrong-plane access remains outside scope. +- Destructive confirmation standard: PASS. `Pause AI execution` and `Resume AI execution` remain confirmation-protected actions on the existing controls page. +- Global search safety: PASS / N/A. No new Resource, Global Search entry, or tenantless AI list is introduced. +- OperationRun and Ops-UX: PASS by non-use. This slice creates no `OperationRun`, queue, notification lifecycle, or Monitoring link. +- Data minimization: PASS. Audit stores decision metadata only; raw prompt, source payload, and output text remain excluded. +- Test governance (TEST-GOV-001): PASS. Proof stays in narrow unit + feature + architecture-guard coverage; no browser or heavy-governance family is required by default. +- Proportionality / no premature abstraction: PASS with bounded exception. One governed AI boundary and one bounded use-case catalog are justified by two concrete future consumers and safety needs; no provider marketplace, queue pipeline, or persistence layer is introduced. +- Persisted truth (PERSIST-001): PASS. Workspace AI policy reuses existing workspace settings; no AI table, cache, result store, or prompt ledger is added. +- Behavioral state (STATE-001): PASS. `disabled` and `private_only` directly change execution eligibility; provider classes and data classifications directly change allow/block behavior. +- Shared pattern first / UI semantics / Filament native UI: PASS. Existing settings, controls, and audit primitives are reused; no custom AI shell, second status framework, or duplicate truth surface is introduced. +- Provider boundary (PROV-001): PASS. Shared terms stay vendor-neutral (`provider class`, `data classification`, `AI use case`), and direct provider-specific seams are deferred. +- Filament/Laravel panel safety: PASS. Livewire v4 remains the Filament v5 runtime, `SystemPanelProvider` stays on the existing `/system` panel, and no provider-registration change beyond `bootstrap/providers.php` is needed. +- Asset strategy: PASS. No new panel-only or shared asset registration is planned; deployment keeps the normal `cd apps/platform && php artisan filament:assets` step if implementation later registers assets. + +**Gate evaluation**: PASS (no constitution violation is required to deliver the narrow v1 slice). + +- The governed boundary is an in-process decision seam only; it does not create provider execution, queueing, or result persistence. +- Workspace policy truth stays inside the existing settings stack and reuses existing audit behavior. +- The system kill switch reuses the existing operational-control evaluator and controls page rather than creating a second AI control surface. + +**Post-design re-check**: PASS (design artifacts: [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), [contracts/private-ai-governance.openapi.yaml](contracts/private-ai-governance.openapi.yaml)). + +## Test Governance Check + +> **Fill for any runtime-changing or test-affecting feature. Docs-only or template-only work may state concise `N/A` or `none`.** + +- **Test purpose / classification by changed surface**: Unit for the catalog, request/decision contract, operational-control precedence, and audit metadata shaping; Feature for the workspace settings and system controls surfaces; Feature/Guard for the no-direct-provider invariant +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: unit coverage proves the decision matrix without Filament boot cost, feature coverage proves the two existing operator surfaces plus authorization/audit integration, and one architecture guard protects against local provider bypasses; browser and heavy-governance coverage add cost without proving new business truth +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Ai/AiUseCaseCatalogTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Ai/AiDecisionAuditMetadataTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Ai/GovernedAiExecutionBoundaryTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceAiPolicySettingsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsNonMemberNotFoundTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsAuditTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsControls/AiExecutionOperationalControlTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsControls/OperationalControlManagementTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoDirectAiProviderBypassTest.php` +- **Fixture / helper / factory / seed / context cost risks**: low-to-moderate; reuse existing workspace settings, membership, platform-user, and operational-control fixtures, but avoid browser harnesses, provider emulators, or seeded AI history +- **Expensive defaults or shared helper growth introduced?**: no; the AI boundary should accept simple value objects/arrays, and feature tests should avoid broad `WorkspaceSettingsManageTest.php` workflow setup unless an implementation change genuinely needs that depth +- **Heavy-family additions, promotions, or visibility changes**: none expected; do not promote this slice into browser or heavy-governance families by default +- **Surface-class relief / special coverage rule**: standard-native-filament relief for the two existing pages, plus one direct service-level rule that blocked requests produce no provider resolution +- **Closing validation and reviewer handoff**: rerun the twelve focused test commands above, verify that `ai.execution` uses the existing operational-control path, verify that workspace policy changes still reuse the existing settings authorization and audit behavior, and verify that no app-level AI provider client exists outside the governed boundary +- **Budget / baseline / trend follow-up**: none expected; if workspace settings coverage broadens into the existing heavy-governance family, document the lane cost in-feature rather than hiding it +- **Review-stop questions**: lane fit, breadth, hidden setup cost, architecture-guard coverage, accidental provider/runtime scope growth +- **Escalation path**: `document-in-feature` for contained lane drift; `reject-or-split` if implementation introduces browser/heavy-governance cost, queue semantics, or provider integration +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: routine narrow test upkeep stays inside this feature; broader AI runtime and provider workflows are already deferred to follow-up candidates + +## Project Structure + +### Documentation (this feature) + +```text +specs/248-private-ai-policy-foundation/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── private-ai-governance.openapi.yaml +└── tasks.md # Created later by /speckit.tasks, not by this plan step +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/Pages/Settings/WorkspaceSettings.php +│ ├── Filament/System/Pages/Ops/Controls.php +│ ├── Providers/Filament/SystemPanelProvider.php +│ ├── Services/Audit/ +│ │ ├── AuditRecorder.php +│ │ └── WorkspaceAuditLogger.php +│ ├── Services/Settings/ +│ │ ├── SettingsResolver.php +│ │ └── SettingsWriter.php +│ ├── Support/Audit/AuditActionId.php +│ ├── Support/Auth/ +│ │ ├── Capabilities.php +│ │ └── PlatformCapabilities.php +│ ├── Support/OperationalControls/ +│ │ ├── OperationalControlCatalog.php +│ │ └── OperationalControlEvaluator.php +│ ├── Support/ProductKnowledge/ContextualHelpResolver.php +│ ├── Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php +│ └── Support/Ai/ # likely new narrow namespace if implementation proceeds +└── tests/ + ├── Feature/SettingsFoundation/ + ├── Feature/OperationalControls/ + ├── Feature/System/OpsControls/ + ├── Feature/Guards/ + ├── Unit/Support/OperationalControls/ + ├── Unit/Support/ProductKnowledge/ + └── Unit/Support/Ai/ +``` + +**Structure Decision**: Laravel monolith. Implementation stays entirely inside `apps/platform`, reusing existing settings, audit, and operational-control seams while adding only one narrow AI support namespace if code work later proceeds. + +## Complexity Tracking + +> **Fill when Constitution Check has violations that must be justified OR when BLOAT-001 is triggered by new persistence, abstractions, states, or semantic frameworks.** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| BLOAT-001 — governed AI decision boundary | One central allow/block seam is the smallest safe place to enforce workspace policy, operational controls, provider class gating, and audit metadata before any future AI caller can reach a model | Per-surface AI helpers would duplicate policy/control/audit logic and create bypass risk across product knowledge and diagnostics | +| BLOAT-001 — code-owned AI use-case catalog | Two concrete future adopters need a single allowlist and stable vocabulary now | Free-form string keys spread across callers would drift and be difficult to guard or audit consistently | +| STATE-001 — AI policy / provider / data-classification families | These values directly change whether execution is allowed and what may cross the trust boundary | Vendor names or presentation-only labels would not be enforceable, portable, or sufficiently reviewable | + +## Proportionality Review + +> **Fill when the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table/artifact, interface/contract/registry/resolver, taxonomy/classification system, or cross-domain UI framework.** + +- **Current operator problem**: TenantPilot has no safe app-level AI seam today, so future AI work would otherwise begin as local provider calls and local prompt/policy logic that bypass workspace isolation, runtime controls, and auditability. +- **Existing structure is insufficient because**: the repo already has settings, operational controls, and audit infrastructure, but it has no place to classify AI use cases, provider trust classes, or data classifications, and no single decision service that every caller must use. +- **Narrowest correct implementation**: add one workspace setting (`ai.policy_mode`), one operational control key (`ai.execution`), one code-owned use-case catalog for exactly two internal-only consumers, one request/decision contract, and one audit metadata shape. Do not add provider adapters, queue semantics, result persistence, or customer-visible AI surfaces. +- **Ownership cost created**: maintain 2 use-case entries, 2 policy values, 2 provider classes, 6 data classifications, one bounded audit action/metadata shape, and one architecture guard. +- **Alternative intentionally rejected**: local AI helpers on each future surface and a broader multi-provider AI platform were both rejected because they either create safety drift or import speculative architecture before the first real runtime need exists. +- **Release truth**: current-release governance foundation and future-feature preflight seam; not a full AI execution product. + +## Phase 0 — Research (output: research.md) + +Research resolved the remaining implementation-shaping decisions: + +- Reuse `WorkspaceSettings` plus `SettingsRegistry` / `SettingsWriter` for workspace-owned AI policy truth. +- Reuse `OperationalControlCatalog` / `OperationalControlEvaluator` and the existing `Controls` page for `ai.execution` rather than creating a second AI control surface. +- Model v1 as a governed decision boundary, not a provider runtime, queue, or result store. +- Lock the first slice to two code-owned internal use cases tied to `ContextualHelpResolver` and the support-diagnostics bundle path. +- Reuse existing audit infrastructure and keep the AI audit family minimal. + +**Output**: [research.md](research.md) + +## Phase 1 — Design (outputs: data-model.md, contracts/, quickstart.md) + +Design artifacts capture the narrow implementation shape: + +- Existing persisted truth reused: `workspace_settings`, `operational_control_activations`, and `audit_logs`. +- New code-owned truth: AI policy mode, provider class, data classification, approved use-case definitions, and request/decision envelopes. +- Conceptual contracts cover the existing workspace settings page, the existing system controls page, and the in-process governed decision schema. +- Quickstart documents the intended slice order, validation commands, Filament/Livewire assumptions, and the no-new-assets posture. + +**Artifacts**: + +- [data-model.md](data-model.md) +- [contracts/private-ai-governance.openapi.yaml](contracts/private-ai-governance.openapi.yaml) +- [quickstart.md](quickstart.md) + +## Phase 2 — Planning (for tasks.md) + +Dependency-ordered implementation outline for the later `tasks.md` step: + +1. Extend the existing settings registry and workspace settings page with `ai.policy_mode` and plain-language explanation content, without broadening the singleton settings workflow. +2. Add `ai.execution` to the operational-control catalog and controls page, keeping pause/resume confirmation-protected and audit-backed. +3. Introduce a narrow `Support/Ai` namespace containing the use-case catalog, request/decision value objects, and the governed decision boundary only. +4. Reuse the existing audit pipeline for workspace policy mutations and add one bounded AI decision action/metadata shape for allow/block evaluations. +5. Name `ContextualHelpResolver` and `SupportDiagnosticBundleBuilder` as the first adopters, but do not ship customer-facing AI UI, model-provider runtime code, or direct caller wiring beyond what the boundary contract itself requires. +6. Add focused unit, feature, and architecture-guard tests while keeping browser and heavy-governance families out of scope by default. +7. Run focused tests and Pint after implementation; no asset build is expected unless implementation later registers Filament assets. + +## Post-Implementation Close-Out + +- **Implementation status**: Implemented and validated on 2026-04-27. +- **TEST-GOV-001 outcome**: PASS. Proof stayed in focused Pest `Unit` and `Feature` lanes plus one architecture guard, with no browser or heavy-governance suite expansion. +- **Executed validation summary**: + - AI boundary unit lane: 8 tests, 83 assertions passed. + - AI execution controls feature lane: 1 test, 34 assertions passed. + - Operational controls regression lane: 11 tests, 167 assertions passed. + - Workspace settings lane: 20 tests, 267 assertions passed. + - Platform authorization semantics lane: 6 tests, 26 assertions passed. + - No-direct-provider guard lane: 1 test, 1 assertion passed. + - Approved source-input lane: 2 tests, 30 assertions passed. + - Adjacent product-knowledge/support-diagnostics regression lane: 14 tests, 107 assertions passed. + - Final targeted feature validation rollup: 42 tests, 530 assertions passed. + - Formatting: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` passed. +- **Catalog lock and tenant-context declaration**: + - `product_knowledge.answer_draft`: `tenant_context_permitted = false` + - `support_diagnostics.summary_draft`: `tenant_context_permitted = true` + - Boundary coverage plus the approved source adapters preserved that split. +- **Browser smoke result**: PASS. + - `/admin/settings/workspace`: authenticated as a workspace manager, changed `Workspace AI policy` from the default effective disabled state to `Private only`, saved successfully, and confirmed the effective summary plus approved-use-case/provider-class copy updated on the real page. + - `/system/ops/controls`: authenticated as a platform operator, opened the `AI execution` card, paused execution with confirmation and reason text, confirmed the `Paused globally` state and success notification, then resumed execution and confirmed the enabled state returned. +- **Environment note**: the integrated browser carried a stale or poisoned `localhost` system-panel session during smoke work. The product routes themselves were healthy; the system-panel smoke path completed successfully on `127.0.0.1` to get a clean host-scoped browser session. This was an environment/browser-session workaround, not a feature bug. +- **Guardrail close-out**: no confirmed in-scope findings remained after the code, validation, browser smoke, and artifact analysis loop. No new provider runtime, queue, result persistence, or customer-facing AI surface was introduced. +- **Follow-up-spec deferrals retained**: + - public or external-provider execution + - result persistence, cache, or prompt/output history + - AI budgeting, credits, or cost controls + - queued AI execution or `OperationRun` semantics + - customer-facing AI workflows or approval flows diff --git a/specs/248-private-ai-policy-foundation/quickstart.md b/specs/248-private-ai-policy-foundation/quickstart.md new file mode 100644 index 00000000..b136b10e --- /dev/null +++ b/specs/248-private-ai-policy-foundation/quickstart.md @@ -0,0 +1,76 @@ +# Quickstart — Private AI Execution & Policy Foundation + +## Preconditions + +- Docker is running. +- `apps/platform` dependencies are installed. +- This slice stays inside the existing Laravel / Filament runtime and does not introduce a second AI service. + +## Intended Implementation Order + +1. Add `ai.policy_mode` to the existing settings registry and workspace settings page. +2. Add `ai.execution` to the existing operational-control catalog and controls page. +3. Add a narrow `app/Support/Ai/` namespace containing the use-case catalog, request/decision value objects, and the governed decision boundary only. +4. Reuse the existing audit pipeline for workspace policy mutation and AI decision logging. +5. Add the no-direct-provider architecture guard and the focused unit/feature tests. + +## Targeted Validation Commands (after implementation) + +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Ai/AiUseCaseCatalogTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Ai/AiDecisionAuditMetadataTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Ai/GovernedAiExecutionBoundaryTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceAiPolicySettingsTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsNonMemberNotFoundTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsAuditTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsControls/AiExecutionOperationalControlTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsControls/OperationalControlManagementTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoDirectAiProviderBypassTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## Manual Smoke (after implementation) + +1. Sign in to `/admin`, select a workspace, and open `/admin/settings/workspace`. +2. As a workspace manager, switch the AI policy between `Disabled` and `Private only` and confirm the page shows the allowed use cases, provider classes, and blocked data classes in plain language. +3. Sign in to `/system` as a platform operator with `platform.access_system_panel` and `platform.ops.controls.manage`, then open `/system/ops/controls`. +4. Pause `AI execution`, confirm the global reason/expiry flow, and verify that the control state is visible before resuming it. +5. Exercise the governed AI boundary through focused tests or a narrow internal stub caller only; no customer-facing AI route or UI is part of v1. + +## Implementation Outcome (2026-04-27) + +- `TEST-GOV-001`: PASS. +- Focused validation stayed in Pest `Unit` plus `Feature` lanes with one architecture guard only. +- Executed validation summary: + - AI boundary unit lane: 8 tests, 83 assertions passed. + - AI execution controls feature lane: 1 test, 34 assertions passed. + - Operational controls regression lane: 11 tests, 167 assertions passed. + - Workspace settings lane: 20 tests, 267 assertions passed. + - Platform authorization semantics lane: 6 tests, 26 assertions passed. + - No-direct-provider guard lane: 1 test, 1 assertion passed. + - Approved source-input lane: 2 tests, 30 assertions passed. + - Adjacent product-knowledge/support-diagnostics regression lane: 14 tests, 107 assertions passed. + - Final targeted feature validation rollup: 42 tests, 530 assertions passed. + - Pint: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` passed. +- Catalog lock and tenant-context declaration: + - `product_knowledge.answer_draft`: `tenant_context_permitted = false` + - `support_diagnostics.summary_draft`: `tenant_context_permitted = true` +- Browser smoke completed: + 1. `/admin/settings/workspace`: saved `Workspace AI policy = Private only` and confirmed the effective summary updated on the real page. + 2. `/system/ops/controls`: paused and resumed `AI execution` through the confirmation flow and confirmed both state changes plus success notifications. +- Environment note: the integrated browser's `localhost` system-panel session became stale during smoke work, so the system-panel step completed on `127.0.0.1` with a fresh host-scoped session. Route health and product behavior were otherwise unchanged. +- Deferred to follow-up specs only: + - external-public or broader provider execution + - result persistence, caching, or prompt/output history + - budgeting, credits, or cost controls + - queued AI work or `OperationRun` semantics + - customer-facing AI surfaces or approval workflows + +## Notes + +- Filament v5 already runs on Livewire v4 in this repo. +- Panel providers remain registered through `bootstrap/providers.php`; this slice does not add or move providers. +- No new globally searchable AI resource is part of v1, so global search behavior stays unchanged. +- `Pause AI execution` and `Resume AI execution` are the only destructive-like actions in scope and must stay confirmation-protected. +- No new registered assets are expected. If implementation later registers a Filament asset anyway, deployment still needs the normal `cd apps/platform && php artisan filament:assets` step. \ No newline at end of file diff --git a/specs/248-private-ai-policy-foundation/research.md b/specs/248-private-ai-policy-foundation/research.md new file mode 100644 index 00000000..36df5d6f --- /dev/null +++ b/specs/248-private-ai-policy-foundation/research.md @@ -0,0 +1,142 @@ +# Research — Private AI Execution & Policy Foundation + +**Date**: 2026-04-27 +**Spec**: [spec.md](spec.md) + +This document resolves planning unknowns and records the repo-backed decisions that keep Spec 248 narrow. + +## Decision 1 — Reuse workspace settings for AI policy truth + +**Decision**: Store workspace AI posture as a workspace setting at `ai.policy_mode` on the existing [WorkspaceSettings](../../apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php) page, with validation registered through [SettingsRegistry](../../apps/platform/app/Support/Settings/SettingsRegistry.php) and persistence/audit handled by [SettingsWriter](../../apps/platform/app/Services/Settings/SettingsWriter.php). + +**Rationale**: +- The repo already has a singleton workspace settings surface, a central settings registry, and an audited writer path. +- Reusing that stack preserves workspace ownership and avoids inventing a second admin surface or a new AI persistence table. +- The existing workspace settings capabilities already separate view and manage permissions. + +**Evidence**: +- [WorkspaceSettings](../../apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php) already owns the `/admin/settings/workspace` singleton route and uses `Capabilities::WORKSPACE_SETTINGS_VIEW` / `Capabilities::WORKSPACE_SETTINGS_MANAGE`. +- [SettingsRegistry](../../apps/platform/app/Support/Settings/SettingsRegistry.php) is the canonical place for setting definitions and validation. +- [SettingsWriter](../../apps/platform/app/Services/Settings/SettingsWriter.php) already persists workspace settings and records `workspace_setting.updated` / `workspace_setting.reset` audit events. + +**Alternatives considered**: +- Add a dedicated `workspace_ai_policies` table. + - Rejected: new persisted truth is unnecessary for a single workspace-owned mode and would violate the narrow v1 scope. +- Hide AI posture in environment config or feature flags. + - Rejected: not workspace-owned, not operator-auditable, and not compatible with the product requirement for explicit workspace policy. + +## Decision 2 — Reuse the existing operational-controls path for the runtime stop + +**Decision**: Add `ai.execution` to [OperationalControlCatalog](../../apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php), evaluate it through [OperationalControlEvaluator](../../apps/platform/app/Support/OperationalControls/OperationalControlEvaluator.php), and expose it only on the existing [Controls](../../apps/platform/app/Filament/System/Pages/Ops/Controls.php) page under the current `/system` panel. + +**Rationale**: +- The repo already has a platform-only control-center pattern with confirmation, scope previews, and audit logging. +- Reusing it avoids a second AI-specific emergency-stop mechanism or a new system AI console. +- The platform plane auth guard and capability checks are already in place for this page. + +**Evidence**: +- [Controls](../../apps/platform/app/Filament/System/Pages/Ops/Controls.php) already owns confirmation-protected pause/resume actions and history for operational controls. +- [OperationalControlCatalog](../../apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php) is the existing source of control keys, labels, and supported scopes. +- [OperationalControlEvaluator](../../apps/platform/app/Support/OperationalControls/OperationalControlEvaluator.php) is the existing runtime lookup path. +- [SystemPanelProvider](../../apps/platform/app/Providers/Filament/SystemPanelProvider.php) and [PlatformCapabilities](../../apps/platform/app/Support/Auth/PlatformCapabilities.php) already enforce the `/system` plane and `platform.ops.controls.manage` capability. + +**Alternatives considered**: +- Add an AI-specific console or admin page under `/system`. + - Rejected: duplicates the existing ops-controls pattern and broadens v1 without adding new product truth. +- Use a deploy-time environment flag as the emergency stop. + - Rejected: not operator-owned, not auditable, and not aligned with the current control-center workflow. + +## Decision 3 — Treat v1 as a governed decision boundary, not an AI provider runtime + +**Decision**: The new AI seam should be an in-process governed decision boundary that accepts a registered use-case request and returns an allow/block decision plus audit-ready metadata. It must not include provider adapters, outbound model execution, queue orchestration, or result persistence in this slice. + +**Rationale**: +- The spec explicitly avoids direct external provider calls with tenant data, `OperationRun` semantics, result persistence, and a broad marketplace. +- The repo has no existing AI execution layer, so the smallest safe first step is the allow/block contract itself. +- A decision-first seam is enough to stop local provider calls from appearing feature by feature. + +**Evidence**: +- There is no app-level AI support namespace in `apps/platform/app/**` today. +- Existing shared seams cover settings, ops controls, audit, product knowledge, and support diagnostics, but none of them own AI allow/block semantics. + +**Alternatives considered**: +- Add feature-local AI helpers in product knowledge and diagnostics first. + - Rejected: duplicates policy, provider-class, and data-classification rules across surfaces. +- Build a full provider abstraction layer now. + - Rejected: speculative architecture before the first concrete provider runtime is even in scope. + +## Decision 4 — Lock v1 to two approved internal-only use cases and derive them from existing seams + +**Decision**: Keep the v1 catalog locked to exactly two use cases: + +- `product_knowledge.answer_draft`, anchored to [ContextualHelpResolver](../../apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php) and its code-owned knowledge source +- `support_diagnostics.summary_draft`, anchored to [SupportDiagnosticBundleBuilder](../../apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php) as a derived summary path + +**Rationale**: +- These are the two named likely adopters from the spec and both already exist as internal-only seams. +- Limiting the catalog to two concrete consumers satisfies ABSTR-001 while still proving the shared decision vocabulary is reusable. +- Open-ended catalog growth would silently widen scope into a general AI platform. + +**Evidence**: +- [ContextualHelpResolver](../../apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php) already exposes `knowledgeSource()` for code-owned product knowledge. +- [SupportDiagnosticBundleBuilder](../../apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php) already produces the diagnostics data family used from the tenant dashboard and the tenantless operation viewer. + +**Alternatives considered**: +- Allow any caller to register arbitrary AI use cases at runtime. + - Rejected: creates speculative platform scope and weakens governance. +- Ship only one adopter in v1. + - Rejected: the safety justification for the central catalog is stronger with the two real future consumers already identified by the spec. + +## Decision 5 — Support diagnostics input must be a derived redacted summary, not the raw bundle + +**Decision**: `support_diagnostics.summary_draft` should consume a derived redacted summary of the support-diagnostics bundle, not the raw `sections` array or the raw provider/context payloads already present in the bundle structure. + +**Rationale**: +- The current support-diagnostics bundle is broad, structured, and designed for operator inspection, not AI transport. +- Passing the raw bundle would violate the explicit v1 ban on raw provider payloads, customer-confidential data, and raw evidence excerpts. +- A derived summary keeps the AI boundary honest: if the summary cannot be produced safely, the use case should stay blocked. + +**Evidence**: +- [SupportDiagnosticBundleBuilder](../../apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php) currently produces a rich `sections` structure plus contextual help and redaction notes, not a purpose-built AI summary. + +**Alternatives considered**: +- Feed the full support-diagnostics bundle into AI with field-level filtering. + - Rejected: still too broad for v1, easier to get wrong, and unnecessary for the first governed foundation slice. + +## Decision 6 — Reuse the existing audit pipeline and keep the AI audit family minimal + +**Decision**: Reuse [WorkspaceAuditLogger](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) and the underlying [AuditActionId](../../apps/platform/app/Support/Audit/AuditActionId.php) / `AuditRecorder` path. Keep workspace policy mutations on the existing `workspace_setting.updated` / `workspace_setting.reset` actions and add one bounded AI decision action ID for governed decision evaluations with structured metadata only. + +**Rationale**: +- Policy changes already flow through the workspace settings audit path and should not create a second mutation pattern. +- AI decision evaluations need a stable audit record, but the narrowest shape is one action ID plus metadata, not a full AI run ledger. +- The spec explicitly bans raw prompt, raw source payload, and output persistence. + +**Evidence**: +- [SettingsWriter](../../apps/platform/app/Services/Settings/SettingsWriter.php) already logs workspace-setting updates and resets. +- [WorkspaceAuditLogger](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) already records workspace-scoped and tenant-scoped audit entries. +- [AuditActionId](../../apps/platform/app/Support/Audit/AuditActionId.php) is the canonical action registry. + +**Alternatives considered**: +- Add a dedicated AI audit table or prompt history store. + - Rejected: violates the v1 no-new-persistence constraint and imports a second source of truth. +- Split AI decisions into many action IDs (`allowed`, `blocked`, `control_blocked`, etc.). + - Rejected for v1: one bounded decision action plus metadata is the smaller audit family. + +## Decision 7 — Keep proof narrow: unit + feature + architecture guard + +**Decision**: Prove the slice with narrow unit tests for the decision matrix, focused feature tests for the two existing operator surfaces, and one architecture guard that fails if direct AI-provider access appears outside the governed boundary. + +**Rationale**: +- Unit coverage is the cheapest place to prove the allow/block matrix. +- Feature coverage is still needed because the slice touches the existing workspace settings and system controls surfaces. +- Browser and heavy-governance workflows would add cost without proving additional v1 truth. + +**Evidence**: +- Existing settings and operational-controls tests already show the repo prefers focused Pest feature tests plus targeted unit tests over browser coverage for this class of work. + +**Alternatives considered**: +- Add browser smoke coverage in v1. + - Rejected: unnecessary for the narrow foundation slice and not the cheapest proof. +- Reuse the broad `WorkspaceSettingsManageTest.php` family as the primary proof. + - Rejected: it is workflow-heavy and should not become the default proving lane for a narrow AI policy field. \ No newline at end of file diff --git a/specs/248-private-ai-policy-foundation/spec.md b/specs/248-private-ai-policy-foundation/spec.md new file mode 100644 index 00000000..9389320b --- /dev/null +++ b/specs/248-private-ai-policy-foundation/spec.md @@ -0,0 +1,348 @@ +# Feature Specification: Private AI Execution & Policy Foundation + +**Feature Branch**: `248-private-ai-policy-foundation` +**Created**: 2026-04-27 +**Status**: Implemented +**Input**: User description: "Promote the roadmap-fit candidate Private AI Execution & Policy Foundation as a narrow, implementation-ready slice that introduces a governed central AI execution boundary for approved use cases, workspace policy modes, provider-class gating, and audit-ready decision metadata, while stopping before customer-facing AI features, direct external provider calls with tenant data, or a broad multi-provider marketplace." + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: TenantPilot now has roadmap pressure to add AI-assisted support and operator workflows, but the repo still has no app-level AI execution seam, no workspace-owned AI policy truth, and no central place to classify which AI inputs are ever allowed to leave a bounded trust boundary. +- **Today's failure**: If AI work starts feature-by-feature, it will likely appear as local provider calls, local prompt assembly, and local allow/block logic that bypass workspace policy, provider trust boundaries, operational controls, and audit-ready decision metadata. That would create privacy drift, provider coupling, and rework before the first real customer-facing AI workflow even lands. +- **User-visible improvement**: Workspace operators can set an explicit workspace AI posture on the existing workspace settings surface, platform operators can pause all AI execution through the existing operational-controls path, and future AI-assisted internal workflows get one auditable allow-or-block decision before any model execution begins. +- **Smallest enterprise-capable version**: Add one concrete governed AI execution boundary, one code-owned approved use-case catalog locked to two internal-only future consumers (`product_knowledge.answer_draft` and `support_diagnostics.summary_draft`), one workspace AI policy section with the modes `disabled` and `private_only`, one bounded provider-class and data-classification contract, one reused operational-control key for emergency stop, and one audit metadata shape on the existing audit infrastructure. +- **Explicit non-goals**: No customer-facing AI surface, no chatbot, no customer communication drafting, no autonomous remediation, no human-approval workflow, no broad provider marketplace, no provider credential-management UI, no usage budgeting, no result cache/store, no prompt/template CMS, no queueing/OperationRun layer for AI, and no external public-provider execution with tenant or customer data. +- **Permanent complexity imported**: One workspace-owned AI policy truth inside the existing settings stack, one bounded AI use-case catalog, one bounded provider-class catalog, one bounded AI data-classification family, one concrete execution-decision service, one operational-control catalog entry, new audit action IDs and metadata fields, and focused unit plus feature guard coverage. +- **Why now**: This is the next roadmap-fit foundation after Specs 242-247 and the provider-vocabulary hardening lane. It directly reduces the current risk that private AI arrives through ungoverned local feature calls before the product has safe workspace isolation, provider gating, and audit semantics. +- **Why not local**: A local AI helper per surface would duplicate policy checks, duplicate data-classification choices, and teach parallel provider semantics across product knowledge, diagnostics, and later customer workflows. The trust boundary needs to exist once before those consumers start shipping. +- **Approval class**: Core Enterprise +- **Red flags triggered**: New axes, new meta-infrastructure, and foundation-sounding scope. Defense: the slice is tightly limited to two approved use cases, two policy modes, one existing admin settings surface, one existing system control surface, no new table, no result persistence, and no customer-visible AI workflow. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace, platform +- **Primary Routes**: + - `/admin/settings/workspace` on the existing workspace settings page for workspace-owned AI policy + - `/system/ops/controls` on the existing system operational-controls page for a platform emergency stop of AI execution + - No new tenant/admin AI output route, customer-facing AI page, or system AI console is introduced in v1 +- **Data Ownership**: + - Workspace AI policy truth is workspace-owned and stored through the existing workspace settings mechanism rather than a new AI table + - Approved AI use cases, provider classes, and AI data classifications remain code-owned repository truth + - AI execution decisions and policy mutations are recorded on the existing audit infrastructure; no AI result ledger, cache store, or prompt history table is introduced in this slice + - Tenant-scoped AI requests may carry workspace and tenant identifiers for authorization and audit context, but tenant/customer content remains derived input only and is not persisted as a new AI-owned record family +- **RBAC**: + - Workspace AI policy visibility and mutation stay on the existing workspace settings authorization path and reuse the current workspace settings capabilities + - Platform pause/resume of AI execution stays on the existing system panel and requires `PlatformCapabilities::ACCESS_SYSTEM_PANEL` plus `PlatformCapabilities::OPS_CONTROLS_MANAGE` + - The governed AI execution boundary accepts requests only after the caller has already resolved workspace and optional tenant entitlement on the host surface; it does not create a new cross-plane shortcut from `/system` into tenant data + - This slice introduces no new customer-facing or operator-facing `run AI` capability string because it intentionally stops before any new AI action surface is exposed + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: N/A - this slice does not add a canonical cross-tenant AI list or detail route +- **Explicit entitlement checks preventing cross-tenant leakage**: AI decision evaluation never runs before the host surface has already resolved workspace and tenant entitlement. A non-member or wrong-scope actor receives the existing 404 semantics before any AI policy or data-classification detail is revealed. + +## 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 +- **Interaction class(es)**: workspace settings, operational safety controls, audit logging, future support-diagnostic and product-knowledge source reuse +- **Systems touched**: existing workspace settings persistence and audit flow, `App\Support\OperationalControls\OperationalControlEvaluator`, `App\Filament\System\Pages\Ops\Controls`, `App\Support\ProductKnowledge\ContextualHelpResolver`, existing support-diagnostic bundle builders, and `App\Support\Audit\AuditActionId` +- **Existing pattern(s) to extend**: workspace settings update/reset audit path, operational-controls evaluation path, platform system-panel capability enforcement, and stable audit action ID conventions +- **Shared contract / presenter / builder / renderer to reuse**: `SettingsResolver`, `SettingsWriter`, `WorkspaceAuditLogger`, `AuditRecorder`, `OperationalControlEvaluator`, `AuditActionId`, `ContextualHelpResolver`, and the existing support-diagnostic summary pipeline +- **Why the existing shared path is sufficient or insufficient**: the existing settings, ops-controls, and audit paths are already sufficient for policy storage, emergency stop, and audit ownership. They are insufficient for AI itself because no central execution boundary or AI-specific allow/block decision contract exists yet. +- **Allowed deviation and why**: none. The first slice must not introduce page-local AI policy checks, page-local provider labels, or page-local audit payloads. +- **Consistency impact**: the same vocabulary for `AI policy mode`, `provider class`, `data classification`, `approved use case`, `blocked reason`, and `private-only` must appear consistently across workspace settings, system controls, audit prose, and all future AI decision callers. +- **Review focus**: reviewers must block any direct provider call, raw feature-level AI helper, or local data-classification rule that bypasses the central AI execution boundary, the workspace AI policy, or the reused operational-control decision. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: no +- **Shared OperationRun UX contract/layer reused**: N/A - this slice intentionally stops before queueing, background AI work, or customer/operator-facing AI runs +- **Delegated start/completion UX behaviors**: N/A +- **Local surface-owned behavior that remains**: N/A +- **Queued DB-notification policy**: N/A +- **Terminal notification path**: N/A +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: yes +- **Boundary classification**: platform-core +- **Seams affected**: AI use-case keys, workspace AI policy vocabulary, provider-class gating, data-classification gating, and the governed execution decision contract +- **Neutral platform terms preserved or introduced**: `AI use case`, `provider class`, `workspace AI policy`, `data classification`, `execution decision`, `source family`, and `private-only` +- **Provider-specific semantics retained and why**: none in v1. The slice intentionally classifies trust boundaries by provider class rather than naming vendors, endpoints, SDKs, or model marketplaces. +- **Why this does not deepen provider coupling accidentally**: the spec keeps provider truth at the class level (`local_private` versus `external_public`) and forbids feature code from depending on vendor-specific semantics or credentials in this foundation slice. +- **Follow-up path**: later provider expansion belongs in follow-up specs, primarily `AI Usage Budgeting, Context & Result Governance` and then `AI-Assisted Customer Operations`, rather than inside this foundation slice + +## 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 | +|---|---|---|---|---|---|---| +| Workspace settings AI policy section | yes | Native Filament + existing singleton settings page | settings, status messaging, helper text | page, settings section, resolved policy summary | no | Extends the existing workspace settings page instead of creating a separate AI admin surface | +| System ops controls AI execution control card | yes | Native Filament + existing operational-controls page | operational safety controls, audit-backed state messaging | page, card/action state, confirmation modal | no | Reuses the current control-center pattern for a single new AI execution kill switch | +| Customer-facing or tenant-facing AI output surfaces | no | N/A | none | none | no | `N/A - explicitly out of scope for v1` | + +## 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 | +|---|---|---|---|---|---|---|---| +| Workspace settings AI policy section | Primary Decision Surface | Workspace owner or manager decides whether the workspace allows no AI use at all or only private-only AI for approved internal use cases | current policy mode, plain-language effect, approved use cases, allowed provider classes, and blocked data classes | audit attribution, source-family notes, and future-consumer explanation | Primary because this is the one workspace-owned product decision that changes later AI allow/block behavior | Follows configuration-first governance instead of hidden feature flags | Replaces founder memory or code comments with one explicit workspace truth | +| System ops controls AI execution control card | Primary Decision Surface | Platform operator decides whether all new AI execution must be paused during an incident or rollout concern | global control state, reason, expiry, and effect on new AI starts | audit history and affected-use-case summary | Primary because it is the runtime safety stop for the whole AI boundary, not a secondary diagnostic | Follows incident and rollout operations workflow | Removes the need for deploy-time or environment-level emergency stop behavior | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Workspace settings AI policy section | operator-MSP | policy mode, approved use cases, allowed provider classes, blocked data classes, and plain-language effect | last changed attribution and policy-source notes | none | `Save` | vendor-specific credentials, raw prompt examples, raw diagnostic inputs, and future budgeting fields stay out of scope | The same policy vocabulary is reused by the execution boundary and audit prose instead of being restated differently on future surfaces | +| System ops controls AI execution control card | support-platform, operator-platform | control state, reason, expiry, and whether new AI execution is paused | audit history and affected-use-case count | none | `Pause AI execution` or `Resume AI execution` | no prompt content, no provider payload preview, and no workspace content samples appear on the control surface | The control surface owns only runtime stop/start truth; workspace policy detail stays on workspace settings | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Workspace settings AI policy section | Config / Settings / Singleton | Workspace configuration section | Save or reset the workspace AI policy | In-page settings section on the existing singleton route | forbidden | Helper text and policy explanation stay inside the section | none | `/admin/settings/workspace` | `/admin/settings/workspace` | Active workspace context | Workspace AI policy | Whether AI is disabled or private-only, and what that means | existing singleton-settings exception remains valid | +| System ops controls AI execution control card | Utility / System | Operational safety control center | Pause or resume AI execution | Same-page card actions and confirmation modal | forbidden | Audit/history detail remains secondary inside the page | pause/resume stays on the card with confirmation | `/system/ops/controls` | `/system/ops/controls` | Platform-global control scope | AI execution control | Whether new AI execution is allowed right now and why | 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Workspace settings AI policy section | Workspace owner or manager | Decide whether the workspace allows private-only AI for approved internal use cases | Singleton settings page | What AI posture applies to this workspace right now? | policy mode, approved use cases, allowed provider classes, blocked data classes, and plain-language effect | last changed attribution and source-family notes | AI policy mode, provider trust boundary, allowed data scope | TenantPilot only | Save, Reset policy | none | +| System ops controls AI execution control card | Platform operator | Decide whether all new AI execution must be paused or resumed | System control center | Should any new AI execution proceed right now? | global control state, reason, expiry, and effect on new starts | audit history and affected use-case summary | global runtime safety state | TenantPilot only | Pause AI execution, Resume AI execution | Pause AI execution, Resume AI execution | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: yes - workspace-owned AI policy becomes current-release product truth +- **New persisted entity/table/artifact?**: no - workspace AI policy reuses existing workspace settings persistence and audit paths +- **New abstraction?**: yes - one concrete governed AI execution boundary and one bounded use-case catalog +- **New enum/state/reason family?**: yes - AI policy modes, provider classes, data classifications, and execution decision reasons +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: TenantPilot needs a safe way to add AI later without letting support, diagnostics, or customer workflows bypass workspace isolation, private-only trust posture, and auditability. +- **Existing structure is insufficient because**: there is currently no app-level AI seam at all. Existing settings, ops controls, and audit paths can store policy and stop work, but they cannot classify AI input, bind use cases to approved data, or force all future AI callers through one decision. +- **Narrowest correct implementation**: keep persistence inside existing workspace settings, reuse existing system ops controls for the emergency stop, lock the use-case catalog to two internal-only future consumers, classify only the first-slice provider/data families, and write audit metadata to the existing audit log instead of building a second AI record system. +- **Ownership cost**: ongoing review of use-case keys, provider-class vocabulary, data classifications, audit metadata shape, and one architecture guard against direct provider calls +- **Alternative intentionally rejected**: direct feature-level AI helpers were rejected as unsafe; a broad provider registry or marketplace was rejected as speculative; a result ledger, cache, or budgeting system was rejected because the first slice does not yet need those truths. +- **Release truth**: current-release truth that deliberately prepares later AI features without shipping them yet + +### 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**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: unit coverage proves the approved use-case catalog, workspace AI policy resolution, provider-class and data-classification gating, operational-control precedence, and audit-metadata shaping. Focused feature coverage proves the existing workspace settings and system controls surfaces, plus one architecture guard that blocked requests never reach a direct provider call path. +- **New or expanded test families**: focused AI policy and execution-decision unit coverage, workspace settings feature coverage, operational-control integration feature coverage, and one architecture guard that blocks direct AI provider calls outside the governed boundary +- **Fixture / helper cost impact**: low-to-moderate. Reuse existing workspace, membership, settings, platform-user, and system control fixtures. Avoid browser harnesses, provider-emulator suites, or any seeded AI result history. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: standard-native-filament +- **Standard-native relief or required special coverage**: ordinary Filament feature coverage is sufficient for workspace settings and system ops controls. The central AI execution boundary also needs direct service-level tests proving that blocked requests produce no provider call and no raw audit payload. +- **Reviewer handoff**: reviewers must confirm that `ai.execution` uses the existing operational-control path, workspace policy changes reuse the existing settings audit path, unregistered use cases or blocked data classes never reach provider resolution, and no result store, queue, or customer-facing AI surface slipped into the slice. +- **Budget / baseline / trend impact**: low increase in narrow unit and feature coverage only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=WorkspaceAiPolicy` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=GovernedAiExecution` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=AiExecutionArchitectureGuard` + +## First-Slice Approved AI Use Case Inventory *(implementation lock-in for v1)* + +The first slice is locked to the following approved use cases. Adding a third use case requires an explicit spec update. + +| Use Case Key | Intended Future Consumer | Allowed Provider Class(es) | Allowed Data Classification(s) | Visibility | Tenant Context Permitted | Explicitly Excluded Inputs | +|---|---|---|---|---|---|---| +| `product_knowledge.answer_draft` | Product knowledge and contextual help from `ContextualHelpResolver` and related code-owned knowledge sources | `local_private` | `product_knowledge`, `operational_metadata` | internal-only draft | no | tenant policy JSON, raw provider payloads, customer-confidential notes, personal data | +| `support_diagnostics.summary_draft` | Support diagnostics using a redacted summary derived from existing support-diagnostic bundle builders | `local_private` | `redacted_support_summary` | internal-only draft | yes | raw diagnostic bundle sections, raw provider payloads, customer-confidential notes, personal data | + +## First-Slice AI Data Classification Contract *(implementation lock-in for v1)* + +| Data Classification | Meaning In This Slice | V1 Consequence | +|---|---|---| +| `product_knowledge` | Code-owned glossary, contextual-help, and product documentation source content with no tenant/customer payload | Allowed only for approved use cases on `local_private` | +| `operational_metadata` | Minimal non-secret metadata such as safe surface family, route family, or internal workflow context that does not contain tenant/customer content | Allowed only when the approved use case explicitly opts in | +| `redacted_support_summary` | Sanitized support-diagnostic summary content derived from existing product truth without raw provider payloads or customer-confidential detail | Allowed only for `support_diagnostics.summary_draft` on `local_private` | +| `personal_data` | End-user or operator personal data | Blocked for all AI execution in v1 | +| `customer_confidential` | Tenant/customer-confidential narrative, sensitive configuration detail, or customer-owned context that is not reduced to the approved redacted summary | Blocked for all AI execution in v1 | +| `raw_provider_payload` | Raw provider payloads, raw policy JSON, raw Graph/API responses, or equivalent source material | Blocked for all AI execution in v1 | + +## Scope Boundaries *(required for this slice)* + +### In Scope + +- One concrete governed AI execution boundary that all future AI callers must use +- One code-owned approved-use-case catalog locked to `product_knowledge.answer_draft` and `support_diagnostics.summary_draft` +- One workspace-owned AI policy section on the existing workspace settings page with the modes `disabled` and `private_only` +- One bounded provider-class contract with `local_private` and `external_public`, where `external_public` exists only as a blocked trust class in v1 +- One bounded AI data-classification contract as defined above +- One reused operational-control key `ai.execution` on the existing system ops controls surface +- AI decision audit metadata written to the existing audit infrastructure with no prompt/output persistence +- Architecture guardrails that prevent direct provider calls outside the governed boundary + +### Non-Goals + +- Customer-facing AI features, tenant-facing AI summaries, or support-response drafting surfaces +- Broad provider marketplace, vendor credential management, or multi-provider routing UI +- Token or cost budgeting, credits, rate limits, or queue priority rules +- Result cache, prompt store, output history, or reusable AI artifact persistence +- Autonomous remediation, legal/customer communications, or human-approval workflow for AI outputs +- External public-provider execution with tenant/customer data +- Queueing, retries, or `OperationRun` semantics for AI execution in this slice + +## Assumptions + +- The existing workspace settings persistence and audit path are sufficient for storing one workspace AI policy mode without introducing a new table. +- The operational-controls foundation from the existing controls page can safely absorb one additional control key for AI execution. +- `ContextualHelpResolver` and support-diagnostic builders can provide code-owned or redacted source inputs without requiring raw provider payloads to cross the AI boundary. +- The first slice remains internal-only and draft-only, so no customer-visible AI wording, approval queue, or outbound communication contract is needed yet. + +## Risks + +- If the support-diagnostic pipeline cannot produce a clearly redacted summary without raw provider payloads or customer-confidential detail, `support_diagnostics.summary_draft` may need a tighter pre-step before implementation proceeds. +- If the operational-controls slice is unavailable or materially different at implementation time, the `ai.execution` emergency stop may need sequencing adjustment before this feature can land safely. +- A later implementer could still try to add a vendor-specific provider seam or prompt history while wiring the first private model. The architecture guard must stay explicit so the slice does not widen silently. +- A workspace policy surface without an enforced central execution boundary would create false confidence. The execution guard and architecture guard are both mandatory for safe implementation. + +## Follow-up Candidates + +- AI Usage Budgeting, Context & Result Governance +- AI-Assisted Customer Operations +- Decision-pack or review-workspace AI draft assistance after explicit human-approval and evidence-governance rules exist + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Set workspace AI posture once (Priority: P1) + +As a workspace owner or manager, I want to choose whether the workspace disables AI entirely or allows only private-only AI for approved internal use cases so the product has one explicit trust posture before any AI feature is added. + +**Why this priority**: The foundation is not safe unless workspace-owned AI posture is explicit, auditable, and visible before later AI use cases appear. + +**Independent Test**: Open the existing workspace settings page, change the AI policy between `disabled` and `private_only`, and verify that the resolved policy explanation updates and is attributable without touching application code or environment flags. + +**Acceptance Scenarios**: + +1. **Given** a workspace manager opens workspace settings, **When** they save the AI policy mode as `private_only`, **Then** the page shows that only approved private-only use cases may proceed and the change is attributable through the existing workspace settings audit path. +2. **Given** the same workspace changes the mode back to `disabled`, **When** the page reloads, **Then** the page shows that no AI execution is allowed for the workspace and future approved use cases would block before execution. + +--- + +### User Story 2 - Block unsafe AI requests before any provider call (Priority: P1) + +As the product owner responsible for later AI-assisted operator workflows, I want every in-scope AI request to pass through one governed allow-or-block decision so unapproved use cases, external-public trust classes, or disallowed data classes never reach a provider call. + +**Why this priority**: This is the core safety outcome of the foundation. If requests can still bypass the boundary, the slice fails even if the settings UI exists. + +**Independent Test**: Exercise the governed AI boundary with the two approved use cases and several blocked combinations, and verify that allowed requests only accept the approved private input shape while blocked requests never resolve a provider call. + +**Acceptance Scenarios**: + +1. **Given** a workspace is set to `private_only` and a request uses `support_diagnostics.summary_draft` with `redacted_support_summary`, **When** the governed AI boundary evaluates the request for `local_private`, **Then** it allows the request and records an audit-ready decision without persisting prompt or output text. +2. **Given** the same workspace and use case, **When** a request declares `external_public` as the provider class, **Then** the boundary blocks the request before any provider resolution or outbound call occurs. +3. **Given** any workspace AI mode other than `disabled`, **When** a request includes `raw_provider_payload`, `customer_confidential`, or `personal_data`, **Then** the boundary blocks the request before execution even if the requested provider class is `local_private`. +4. **Given** a request uses an unregistered AI use case key or lacks workspace context, **When** the boundary evaluates it, **Then** the request is rejected and no AI provider call is attempted. + +--- + +### User Story 3 - Pause all AI execution centrally during an incident (Priority: P2) + +As a platform operator, I want to pause all new AI execution from the existing system ops controls surface so rollout problems or privacy concerns can be contained without a deployment. + +**Why this priority**: Reusing the operational-controls pattern is the smallest safe incident stop for a cross-cutting AI boundary. + +**Independent Test**: Pause `ai.execution` from the existing controls page, send an otherwise valid AI request through the governed boundary, and verify that it blocks with the operational-control reason until the control is resumed. + +**Acceptance Scenarios**: + +1. **Given** `ai.execution` is paused from `/system/ops/controls`, **When** an otherwise valid approved AI request is evaluated, **Then** the request is blocked before execution and the block reason identifies the active operational control. +2. **Given** the same control is resumed, **When** the same approved request is retried, **Then** the request follows normal workspace policy and data-classification evaluation again. + +### Edge Cases + +- A request may arrive without workspace context or with tenant context from an unauthorized actor; the host authorization boundary must fail first so the AI layer does not leak tenant or policy detail. +- A support-diagnostic request may contain mixed safe and unsafe source material; if the source cannot be reduced to `redacted_support_summary`, the entire AI request is blocked. +- A workspace may be set to `private_only` while the platform-level `ai.execution` control is paused; the pause control wins and blocks all new starts. +- An AI request may be accepted just before `ai.execution` is paused; the control governs new starts only and does not retroactively mutate any in-flight private execution. +- A later feature may try to introduce a third use case or a new data classification in the same implementation PR; that is out of scope unless the active spec is updated explicitly. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature introduces no Microsoft Graph contract change, no tenant-changing provider write, and no new queued workflow family. It creates a governed decision boundary that must run before any future AI provider execution, while reusing the existing workspace settings, operational-controls, and audit infrastructure. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The slice introduces new AI-specific vocabulary and one new execution boundary because the current-release product now needs a safe first truth for AI policy, provider trust class, and allowed data before broader AI features land. It stays narrow by avoiding new tables, queues, result persistence, or provider-marketplace abstractions. + +**Constitution alignment (XCUT-001):** This slice is cross-cutting across workspace settings, operational controls, audit logging, product-knowledge input, and support-diagnostic input. It must reuse the existing settings and ops-controls paths rather than creating page-local AI settings or emergency-stop logic. + +**Constitution alignment (PROV-001):** AI provider trust is classified through neutral provider classes, not vendor-specific names. Provider-specific semantics and provider credential management remain out of scope. + +**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit and feature lanes. The feature must add one explicit architecture guard proving that AI provider access cannot be called directly outside the governed boundary. + +**Constitution alignment (OPS-UX):** This slice does not create or reuse an `OperationRun`. If a later AI feature becomes queued or operationally relevant, that behavior belongs in a follow-up spec and must adopt the canonical Ops-UX contract then. + +**Constitution alignment (RBAC-UX):** The slice spans workspace `/admin` settings and platform `/system` operational controls. Wrong-plane or non-member access remains 404. Existing workspace settings authorization stays authoritative for policy mutation. Existing system-panel capability enforcement stays authoritative for the emergency stop. The governed AI boundary must not become an authorization bypass for tenant-scoped content. + +**Constitution alignment (BADGE-001):** If policy mode or control state is shown with a badge or status chip, the rendering must reuse existing settings/control status semantics rather than introduce page-local AI color language. + +**Constitution alignment (UI-FIL-001):** The only operator-facing surfaces in scope are existing Filament pages. The feature must use native sections, helper text, callouts, actions, and control cards rather than a custom AI admin shell. + +**Constitution alignment (UI-NAMING-001):** Primary operator-facing labels must stay implementation-light and product-truthful: `Workspace AI policy`, `Disabled`, `Private only`, `Approved AI use cases`, `Blocked data classes`, and `AI execution`. Terms such as vendor names, SDK names, or low-level model endpoint jargon stay out of primary labels. + +**Constitution alignment (DECIDE-001):** Workspace settings and system ops controls are the only decision surfaces in scope. No new decision inbox, AI draft viewer, or evidence-heavy AI result page is introduced. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The feature must preserve the existing singleton settings and control-center page patterns. It may not add redundant inspect actions, shadow routes, or mixed action groups for AI management in this first slice. + +**Constitution alignment (ACTSURF-001 - action hierarchy):** Workspace policy mutation stays on the workspace settings page. Platform-wide pause/resume stays on the existing controls page. No other visible AI mutation action is introduced. + +**Constitution alignment (OPSURF-001):** Default-visible content must stay operator-first: whether AI is disabled or private-only for a workspace, and whether all new AI execution is paused globally. No raw prompt content, model internals, or tenant payload excerpts belong on the default surfaces. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** One decision layer is justified because direct reads from raw settings or local feature flags would still force each future AI surface to duplicate provider-class, data-classification, and policy logic. Tests must target business outcomes such as allowed versus blocked execution and clean audit payloads instead of cosmetic rendering alone. + +**Constitution alignment (Filament Action Surfaces):** The action-surface contract remains satisfied. Workspace settings keep a single in-page save model. System ops controls keep confirmation-protected state-change actions on the same surface. No redundant inspect action or empty action group is introduced. + +**Constitution alignment (UX-001 - Layout & Information Architecture):** The workspace AI policy stays inside the existing settings layout with sectioned content and plain-language guidance. The system AI execution stop stays inside the existing controls page. No new custom layout family is introduced. + +### Functional Requirements + +- **FR-248-001 Approved use-case catalog**: The system MUST define a code-owned AI use-case catalog locked to exactly two first-slice keys: `product_knowledge.answer_draft` and `support_diagnostics.summary_draft`. +- **FR-248-002 Use-case declaration contract**: Each first-slice use case MUST declare its allowed provider class, allowed data classifications, source family, visibility (`internal-only draft`), and whether tenant context is permitted. +- **FR-248-003 Workspace AI policy truth**: The system MUST store workspace AI posture through the existing workspace settings mechanism and audit policy changes through the existing workspace settings audit path. +- **FR-248-004 First-slice policy modes**: The first slice MUST support exactly two workspace AI policy modes: `disabled` and `private_only`. +- **FR-248-005 Provider-class contract**: The system MUST define a bounded provider-class contract containing `local_private` and `external_public`, where `external_public` exists only as a blocked trust class in v1. +- **FR-248-006 Data-classification contract**: The system MUST classify AI inputs using the first-slice data classifications defined in this spec and MUST block `personal_data`, `customer_confidential`, and `raw_provider_payload` for all AI execution in v1. +- **FR-248-007 Central execution boundary**: The system MUST route every future AI execution request through one governed execution boundary that requires a registered use case key, actor context, workspace context, requested provider class, declared data classification, and source family before execution is attempted. +- **FR-248-008 Block precedence**: After the host surface has already resolved authorization and scope entitlement, the governed boundary MUST evaluate `ai.execution` operational control, workspace AI policy mode, use-case registration, provider-class allowance, and data-classification allowance before resolving any AI provider call. +- **FR-248-009 Operational-control reuse**: The feature MUST reuse the existing operational-controls pattern through a new in-scope control key `ai.execution` on `/system/ops/controls` rather than introducing a second AI-specific emergency stop mechanism. +- **FR-248-010 Approved source inputs only**: `product_knowledge.answer_draft` MUST consume only code-owned product-knowledge sources, and `support_diagnostics.summary_draft` MUST consume only redacted support-diagnostic summary content. Raw provider payloads, raw policy JSON, and customer-confidential notes are out of scope. +- **FR-248-011 Audit metadata shape**: The system MUST write stable AI-related audit entries for workspace policy changes and AI execution decisions, including at minimum use case key, provider class, workspace AI policy mode, data classification, decision outcome, decision reason, workspace scope, tenant scope when present, source family, and an optional context fingerprint; audit entries MUST NOT store raw prompt text, raw source payloads, or full output text. +- **FR-248-012 No direct provider calls**: Feature code MUST NOT call AI providers directly. A guard test or equivalent architecture check MUST fail if AI provider access appears outside the central governed boundary. +- **FR-248-013 Workspace settings UX**: The existing workspace settings page MUST show the selected AI policy mode, plain-language effect, approved use cases, allowed provider classes, and blocked data classes without introducing vendor-specific admin UI. +- **FR-248-014 Pause semantics**: When `ai.execution` is paused, all new AI execution requests MUST block before provider resolution, while in-flight work already accepted before the pause MAY complete unchanged. +- **FR-248-015 No hidden scope growth**: The first slice MUST NOT introduce customer-facing AI output surfaces, external public-provider execution with tenant/customer data, AI result persistence, cost budgeting, queue/retry behavior, or a provider marketplace. + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Workspace settings AI policy section | `app/Filament/Pages/Settings/WorkspaceSettings.php` | `Save` | N/A - singleton settings page | none | none | N/A | N/A | `Save`; optional `Reset policy` if the page already supports per-setting reset interactions | yes | Reuses the existing workspace settings mutation and audit path; no new AI execution action appears here | +| System ops controls AI execution control card | `app/Filament/System/Pages/Ops/Controls.php` | `Pause AI execution`, `Resume AI execution`, `View history` | Same-page control card or confirmation modal | none | none | none | same-page actions only | `Review impact`, `Save changes`, `Cancel` inside the existing control modal flow | yes | Reuses `PlatformCapabilities::OPS_CONTROLS_MANAGE` and the existing operational-controls action pattern; no new system AI console is introduced | + +### Key Entities *(include if feature involves data)* + +- **Workspace AI Policy**: The workspace-owned policy truth that resolves whether AI is `disabled` or `private_only` for the workspace. +- **Approved AI Use Case Definition**: The code-owned catalog entry that defines one allowed AI purpose, its allowed provider class, allowed data classifications, source family, and visibility. +- **AI Execution Request**: The derived request envelope passed into the governed boundary containing actor, workspace, optional tenant, use case key, provider class, data classification, and source provenance. +- **AI Execution Decision**: The allow-or-block result returned by the governed boundary, including policy mode, matched operational-control state, decision reason, and audit-ready metadata. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-248-001**: In validation scenarios, 100% of in-scope AI requests with an unregistered use case, blocked provider class, blocked data classification, missing workspace context, or active `ai.execution` control are stopped before any provider resolution or outbound call occurs. +- **SC-248-002**: Workspace owners can set and review the workspace AI policy on the existing workspace settings page in under 2 minutes without editing environment variables or code. +- **SC-248-003**: In validation coverage, 0 external-public AI executions occur for tenant/customer data in the first slice. +- **SC-248-004**: The two approved first-slice AI use cases resolve through the same governed decision vocabulary and audit metadata shape, with no direct provider call sites outside the central boundary in guard coverage. diff --git a/specs/248-private-ai-policy-foundation/tasks.md b/specs/248-private-ai-policy-foundation/tasks.md new file mode 100644 index 00000000..797cbd23 --- /dev/null +++ b/specs/248-private-ai-policy-foundation/tasks.md @@ -0,0 +1,194 @@ +--- + +description: "Task list for Private AI Execution & Policy Foundation" + +--- + +# Tasks: Private AI Execution & Policy Foundation + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/contracts/private-ai-governance.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/quickstart.md` + +**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in focused `Unit` and `Feature` lanes, plus one architecture guard, using the targeted Sail commands captured in the feature artifacts. +**Operations**: No new `OperationRun`, queue, retry, monitoring page, or result ledger is introduced. This slice remains DB-backed settings, operational-control, and audit work only. +**RBAC**: Existing workspace settings authorization and platform ops-control authorization remain authoritative. Non-members or wrong-plane actors keep `404` deny-as-not-found semantics where applicable; members missing the required capability receive `403`. +**Provider Boundary**: AI trust vocabulary stays platform-core and vendor-neutral (`AI use case`, `provider class`, `data classification`). `external_public` remains blocked in v1. +**Organization**: Tasks are grouped by user story so workspace AI policy, governed decision enforcement, and operational-stop controls remain independently testable once the shared foundation exists. + +## Test Governance Checklist + +- [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior. +- [x] New or changed tests stay in `apps/platform/tests/Unit/Support/Ai/`, `apps/platform/tests/Feature/SettingsFoundation/`, `apps/platform/tests/Feature/OperationalControls/`, `apps/platform/tests/Feature/System/OpsControls/`, and `apps/platform/tests/Feature/Guards/` only; no browser or heavy-governance lane is added. +- [x] Shared helpers, factories, fixtures, and context defaults stay cheap by default; do not add provider emulators, queue scaffolding, or seeded AI history. +- [x] Planned validation commands cover workspace settings, governed AI decision logic, audit metadata, operational controls, and the no-direct-provider guard without widening scope. +- [x] The declared surface test profile remains `standard-native-filament` because the slice only extends existing workspace settings and system controls pages. +- [x] Any deferred public-provider execution, result persistence, budgeting, or queued AI follow-up resolves as `document-in-feature` or `follow-up-spec`, not as hidden scope growth. + +## Phase 1: Setup (Shared Context) + +**Purpose**: Confirm the bounded first slice, repo seams, and reviewer stop conditions before runtime implementation begins. + +- [x] T001 Review the bounded slice, explicit non-goals, approved use cases, validation lanes, and guardrail expectations in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/contracts/private-ai-governance.openapi.yaml`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/quickstart.md` +- [x] T002 [P] Confirm the existing workspace settings persistence, resolver, and audit seams that this slice must reuse in `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php`, `apps/platform/app/Support/Settings/SettingsRegistry.php`, `apps/platform/app/Services/Settings/SettingsResolver.php`, `apps/platform/app/Services/Settings/SettingsWriter.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` +- [x] T003 [P] Confirm the existing operational-control, platform authorization, and guard-test seams that this slice must extend in `apps/platform/app/Filament/System/Pages/Ops/Controls.php`, `apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php`, `apps/platform/app/Support/OperationalControls/OperationalControlEvaluator.php`, `apps/platform/app/Support/Auth/Capabilities.php`, `apps/platform/app/Support/Auth/PlatformCapabilities.php`, `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php`, `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php`, `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsNonMemberNotFoundTest.php`, `apps/platform/tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the shared AI policy, decision, audit, and operational-stop primitives that every user story depends on. + +**Critical**: No user story work should begin until this phase is complete. + +- [x] T004 [P] Add the `ai.policy_mode` setting definition, allowed values, system default, and resolver plumbing in `apps/platform/app/Support/Settings/SettingsRegistry.php` and `apps/platform/app/Services/Settings/SettingsResolver.php` +- [x] T005 [P] Create the bounded AI support namespace for policy modes, provider classes, data classifications, and request/decision value objects under `apps/platform/app/Support/Ai/` +- [x] T006 Implement the code-owned approved-use-case catalog locked to `product_knowledge.answer_draft` and `support_diagnostics.summary_draft` in `apps/platform/app/Support/Ai/AiUseCaseCatalog.php` and companion definition files under `apps/platform/app/Support/Ai/` +- [x] T007 Implement the governed AI execution boundary so host-surface authorization stays a caller-side precondition, then evaluate `ai.execution`, workspace policy, use-case registration, provider class, and data-classification allowance in `apps/platform/app/Support/Ai/GovernedAiExecutionBoundary.php` +- [x] T008 [P] Add the bounded AI decision audit action and metadata-shaping support without prompt, source-payload, or output persistence in `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, and `apps/platform/app/Support/Ai/` +- [x] T009 [P] Add the `ai.execution` operational-control definition and evaluator lookup path in `apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php` and `apps/platform/app/Support/OperationalControls/OperationalControlEvaluator.php` + +**Checkpoint**: Shared workspace policy, governed AI decision, audit metadata, and runtime stop primitives exist; user stories can now proceed independently. + +--- + +## Phase 3: User Story 1 - Set Workspace AI Posture Once (Priority: P1) MVP + +**Goal**: Let a workspace owner or manager set one explicit workspace AI posture on the existing settings surface before any later AI-assisted workflow is added. + +**Independent Test**: Open `/admin/settings/workspace`, save `disabled` and `private_only`, verify the resolved explanation and approved-use-case summary update on the existing settings page, and confirm authorized and unauthorized actors still get the expected settings semantics. + +### Tests for User Story 1 + +- [x] T010 [P] [US1] Add feature coverage for saving, resetting, and rendering the workspace AI policy section on the existing settings page in `apps/platform/tests/Feature/SettingsFoundation/WorkspaceAiPolicySettingsTest.php` +- [x] T011 [P] [US1] Extend positive and negative workspace-settings authorization coverage so non-members stay `404`, members without manage capability stay `403`, and authorized managers can mutate `ai.policy_mode` in `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php`, `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php`, and `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsNonMemberNotFoundTest.php` +- [x] T012 [P] [US1] Extend workspace-settings audit coverage for AI policy mode updates and resets in `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsAuditTest.php` + +### Implementation for User Story 1 + +- [x] T013 [US1] Add the `Workspace AI policy` section, approved use-case summary, allowed provider-class summary, and blocked data-class explanation to `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` +- [x] T014 [US1] Persist `ai.policy_mode` through the existing audited settings stack in `apps/platform/app/Support/Settings/SettingsRegistry.php`, `apps/platform/app/Services/Settings/SettingsResolver.php`, and `apps/platform/app/Services/Settings/SettingsWriter.php` +- [x] T015 [US1] Keep page-level save and reset behavior, helper text, and default-visible policy explanation derived from the central AI catalog instead of page-local strings in `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` and `apps/platform/app/Support/Ai/` + +**Checkpoint**: User Story 1 is independently functional when the workspace settings page owns one explicit AI posture with correct audit and authorization behavior. + +--- + +## Phase 4: User Story 2 - Block Unsafe AI Requests Before Provider Resolution (Priority: P1) + +**Goal**: Force every in-scope AI request through one governed allow-or-block decision so unregistered use cases, blocked trust classes, and blocked data classifications never reach provider resolution. + +**Independent Test**: Exercise the governed AI boundary with approved and blocked request combinations, verify allowed private-only requests use only approved source families, and prove blocked requests never resolve a provider call. + +### Tests for User Story 2 + +- [x] T016 [P] [US2] Add unit coverage for the approved-use-case catalog and declared provider-class and data-classification rules in `apps/platform/tests/Unit/Support/Ai/AiUseCaseCatalogTest.php` +- [x] T017 [P] [US2] Add unit coverage for boundary precedence across missing workspace context, unregistered use cases, blocked provider classes, blocked data classifications, `disabled`, `private_only`, and allowed private-only requests in `apps/platform/tests/Unit/Support/Ai/GovernedAiExecutionBoundaryTest.php` +- [x] T018 [P] [US2] Add unit coverage for AI decision audit metadata shape and explicit exclusion of prompt text, raw source payloads, raw provider payloads, and output text in `apps/platform/tests/Unit/Support/Ai/AiDecisionAuditMetadataTest.php` +- [x] T019 [P] [US2] Add architecture-guard coverage that no direct AI provider call or vendor-specific runtime entry appears outside the governed boundary in `apps/platform/tests/Feature/Guards/NoDirectAiProviderBypassTest.php` + +### Implementation for User Story 2 + +- [x] T020 [US2] Finalize the governed request and decision contract plus no-provider-resolution behavior inside `apps/platform/app/Support/Ai/GovernedAiExecutionBoundary.php` and its request/decision collaborators under `apps/platform/app/Support/Ai/` +- [x] T021 [US2] Expose only approved source-family inputs for `product_knowledge.answer_draft` and `support_diagnostics.summary_draft` from `apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php` and `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` without adding customer-facing AI UI, public-provider execution, or result persistence +- [x] T022 [US2] Route governed AI decision evaluation through the existing audit pipeline with stable allow-or-block metadata and no prompt/output persistence in `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, and `apps/platform/app/Support/Ai/` + +**Checkpoint**: User Story 2 is independently functional when the central AI boundary blocks unsafe requests before provider resolution and records bounded audit metadata only. + +--- + +## Phase 5: User Story 3 - Pause All AI Execution Centrally During An Incident (Priority: P2) + +**Goal**: Let a platform operator pause and resume new AI execution from the existing system operational-controls surface without introducing a second AI admin console. + +**Independent Test**: Pause `ai.execution` on `/system/ops/controls`, verify an otherwise valid governed AI request blocks with the operational-control reason, then resume the control and verify normal policy evaluation resumes. + +### Tests for User Story 3 + +- [x] T023 [P] [US3] Add feature coverage for pausing and resuming `ai.execution` on the existing controls page, including confirmation-backed state changes and visible control history, in `apps/platform/tests/Feature/System/OpsControls/AiExecutionOperationalControlTest.php` and `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php` +- [x] T024 [P] [US3] Extend positive and negative platform authorization coverage so `platform.access_system_panel` plus `platform.ops.controls.manage` remain authoritative for `ai.execution` pause/resume in `apps/platform/tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php` +- [x] T025 [P] [US3] Extend governed-boundary coverage so an active `ai.execution` control blocks otherwise valid requests until the control is resumed in `apps/platform/tests/Unit/Support/Ai/GovernedAiExecutionBoundaryTest.php` + +### Implementation for User Story 3 + +- [x] T026 [US3] Add the `ai.execution` control definition, operator-facing label, global-only scope, and evaluator lookup semantics in `apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php` and `apps/platform/app/Support/OperationalControls/OperationalControlEvaluator.php` +- [x] T027 [US3] Add the AI execution control card plus confirmation-protected `Pause AI execution` and `Resume AI execution` actions to the existing system controls surface in `apps/platform/app/Filament/System/Pages/Ops/Controls.php` +- [x] T028 [US3] Keep operational-control copy, blocked-reason vocabulary, and control-history presentation aligned across `apps/platform/app/Filament/System/Pages/Ops/Controls.php` and `apps/platform/app/Support/Ai/GovernedAiExecutionBoundary.php` without introducing a new AI capability string or system AI console + +**Checkpoint**: User Story 3 is independently functional when the existing system controls page can pause and resume new AI execution and the boundary honors that stop immediately for new requests. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finish narrow validation, formatting, and reviewer close-out without widening scope. + +- [x] T029 [P] Run the focused unit validation commands recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/quickstart.md` for `apps/platform/tests/Unit/Support/Ai/AiUseCaseCatalogTest.php`, `apps/platform/tests/Unit/Support/Ai/AiDecisionAuditMetadataTest.php`, and `apps/platform/tests/Unit/Support/Ai/GovernedAiExecutionBoundaryTest.php` +- [x] T030 [P] Run the focused workspace-settings validation commands recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/quickstart.md` for `apps/platform/tests/Feature/SettingsFoundation/WorkspaceAiPolicySettingsTest.php`, `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php`, `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php`, `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsNonMemberNotFoundTest.php`, and `apps/platform/tests/Feature/SettingsFoundation/WorkspaceSettingsAuditTest.php` +- [x] T031 [P] Run the focused system-control and architecture-guard validation commands recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/quickstart.md` for `apps/platform/tests/Feature/System/OpsControls/AiExecutionOperationalControlTest.php`, `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php`, `apps/platform/tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/Guards/NoDirectAiProviderBypassTest.php` +- [x] T032 Run dirty-only formatting for touched platform files through `apps/platform/vendor/bin/sail` using the Pint command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/quickstart.md` +- [x] T033 Record the TEST-GOV-001 outcome, guardrail close-out, and any `document-in-feature` or `follow-up-spec` deferrals for public-provider execution, result persistence, budgeting, or queued AI work in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/248-private-ai-policy-foundation/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: no dependencies; start immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories. +- **Phase 3 (US1)**: depends on Phase 2 and establishes the workspace-owned policy truth. +- **Phase 4 (US2)**: depends on Phase 2 and should ship with US1 because policy without a governed boundary would create false confidence. +- **Phase 5 (US3)**: depends on Phase 2 and is safest after US2 because the boundary must already honor `ai.execution` for the system control to be meaningful. +- **Phase 6 (Polish)**: depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: independently testable after Phase 2, but not safe to ship alone. +- **US2 (P1)**: independently testable after Phase 2 and must pair with US1 for a safe MVP. +- **US3 (P2)**: independently testable after Phase 2, but depends on the governed boundary from US2 to prove runtime stop behavior. + +### Within Each User Story + +- Write the listed Pest coverage first and make it fail for the intended behavior gap. +- Complete shared service enforcement before wiring the corresponding Filament surface. +- Re-run the narrowest affected validation command after each story checkpoint before moving to the next story. + +--- + +## Parallel Execution Examples + +### User Story 1 + +- T010, T011, and T012 can run in parallel before runtime edits begin. +- After test scaffolding exists, T013 and T014 can proceed in parallel because the page wiring and settings-stack persistence touch different files; T015 should follow both. + +### User Story 2 + +- T016, T017, T018, and T019 can run in parallel because they cover separate unit and guard files. +- After T020 settles the governed contract, T021 and T022 can proceed in parallel because source-family helpers and audit plumbing live on separate seams. + +### User Story 3 + +- T023, T024, and T025 can run in parallel before implementation starts. +- T026 should land before T027, and T028 should follow both so control-surface wording and boundary reason vocabulary stay consistent. + +--- + +## Implementation Strategy + +### Suggested MVP Scope + +- MVP = **US1 + US2 together**. Workspace policy alone is not safe to ship because the spec explicitly requires the governed boundary that enforces the policy before any provider resolution can occur. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 and US2 together, then validate the settings-backed policy plus governed boundary behavior. +3. Deliver US3 to add the runtime stop on the existing system controls surface. +4. Finish with narrow validation, formatting, and feature-level close-out in Phase 6. + +### Team Strategy + +1. Finish Phase 2 together before splitting story work. +2. Parallelize test authoring inside each story first. +3. Serialize merges around `apps/platform/app/Support/Ai/` and `apps/platform/app/Filament/System/Pages/Ops/Controls.php`, because those seams are shared by multiple story tasks. \ No newline at end of file -- 2.45.2 From aacd82849aeba7f980430675ff74ff9864fc9d82 Mon Sep 17 00:00:00 2001 From: ahmido Date: Tue, 28 Apr 2026 07:15:41 +0000 Subject: [PATCH 23/36] feat(reviews): add CustomerReviewWorkspace with audit logging and RBAC enforcement (#289) Add `CustomerReviewWorkspace` page for tenant pre-filtered reviews Add customer workspace links to `EvidenceSnapshotResource`, `ReviewPackResource`, and `TenantReviewResource` Implement audit logging for `TenantReviewOpened` and `ReviewPackDownloaded` actions Update ReviewPack download controller to enforce tenant-scoped RBAC Add tests for ReviewPack download authorization and audit logging Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/289 --- .github/agents/copilot-instructions.md | 4 +- .../Pages/Reviews/CustomerReviewWorkspace.php | 497 ++++++++++++++++++ .../Resources/EvidenceSnapshotResource.php | 15 + .../Filament/Resources/ReviewPackResource.php | 8 + .../Resources/TenantReviewResource.php | 10 + .../Pages/ViewTenantReview.php | 56 +- .../Widgets/Tenant/TenantReviewPackCard.php | 4 + .../ReviewPackDownloadController.php | 41 +- .../Providers/Filament/AdminPanelProvider.php | 2 + .../app/Services/ReviewPackService.php | 6 +- .../TenantReviewRegisterService.php | 50 ++ .../app/Support/Audit/AuditActionId.php | 6 + .../customer-review-workspace.blade.php | 19 + .../tenant/tenant-review-pack-card.blade.php | 14 + .../CustomerReviewWorkspaceSmokeTest.php | 100 ++++ .../ReviewPack/ReviewPackDownloadTest.php | 16 +- .../Feature/ReviewPack/ReviewPackRbacTest.php | 4 +- ...stomerReviewWorkspaceAuthorizationTest.php | 66 +++ ...CustomerReviewWorkspaceLaunchLinksTest.php | 160 ++++++ .../CustomerReviewWorkspacePackAccessTest.php | 97 ++++ .../CustomerReviewWorkspacePageTest.php | 222 ++++++++ .../checklists/requirements.md | 72 +++ .../customer-review-workspace.openapi.yaml | 261 +++++++++ .../data-model.md | 210 ++++++++ specs/249-customer-review-workspace/plan.md | 310 +++++++++++ .../quickstart.md | 59 +++ .../249-customer-review-workspace/research.md | 166 ++++++ specs/249-customer-review-workspace/spec.md | 299 +++++++++++ specs/249-customer-review-workspace/tasks.md | 205 ++++++++ 29 files changed, 2969 insertions(+), 10 deletions(-) create mode 100644 apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php create mode 100644 apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php create mode 100644 apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php create mode 100644 apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php create mode 100644 apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php create mode 100644 apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php create mode 100644 apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php create mode 100644 specs/249-customer-review-workspace/checklists/requirements.md create mode 100644 specs/249-customer-review-workspace/contracts/customer-review-workspace.openapi.yaml create mode 100644 specs/249-customer-review-workspace/data-model.md create mode 100644 specs/249-customer-review-workspace/plan.md create mode 100644 specs/249-customer-review-workspace/quickstart.md create mode 100644 specs/249-customer-review-workspace/research.md create mode 100644 specs/249-customer-review-workspace/spec.md create mode 100644 specs/249-customer-review-workspace/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index eb417e9e..7b699aba 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -260,6 +260,8 @@ ## Active Technologies - PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned (240-tenant-onboarding-readiness) - PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` (241-support-diagnostic-pack) - PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack) +- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services (249-customer-review-workspace) +- PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace) - PHP 8.4.15 (feat/005-bulk-operations) @@ -294,9 +296,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services - 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` - 240-tenant-onboarding-readiness: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers -- 239-canonical-operation-type-source-of-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4 ### Pre-production compatibility check diff --git a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php new file mode 100644 index 00000000..190a12e7 --- /dev/null +++ b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php @@ -0,0 +1,497 @@ +external_id) + ? (string) $tenant->external_id + : (string) $tenant->getKey(); + + return static::getUrl(panel: 'admin').'?'.http_build_query([ + 'tenant' => $tenantIdentifier, + ]); + } + + /** + * @var array|null + */ + private ?array $authorizedTenants = null; + + public function mount(): void + { + $this->authorizePageAccess(); + $this->applyRequestedTenantPrefilter(); + $this->mountInteractsWithTable(); + } + + protected function getHeaderActions(): array + { + return [ + Action::make('clear_filters') + ->label('Clear filters') + ->icon('heroicon-o-x-mark') + ->color('gray') + ->visible(fn (): bool => $this->hasActiveFilters()) + ->action(function (): void { + $this->clearWorkspaceFilters(); + }), + ]; + } + + public function table(Table $table): Table + { + return $table + ->query(fn (): Builder => $this->workspaceQuery()) + ->defaultSort('name') + ->paginated(TablePaginationProfiles::customPage()) + ->persistFiltersInSession() + ->persistSearchInSession() + ->persistSortInSession() + ->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record)) + ->columns([ + TextColumn::make('name')->label('Tenant')->searchable()->sortable(), + TextColumn::make('latest_review') + ->label('Latest review') + ->badge() + ->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record)) + ->color(fn (Tenant $record): string => $this->latestReviewStateColor($record)) + ->icon(fn (Tenant $record): ?string => $this->latestReviewStateIcon($record)) + ->iconColor(fn (Tenant $record): ?string => $this->latestReviewStateIconColor($record)) + ->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record)) + ->wrap(), + TextColumn::make('finding_summary') + ->label('Key findings') + ->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record)) + ->wrap(), + TextColumn::make('accepted_risk_summary') + ->label('Accepted risks') + ->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record)) + ->wrap(), + TextColumn::make('published_at') + ->label('Published') + ->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString()) + ->dateTime() + ->placeholder('—'), + TextColumn::make('review_pack_state') + ->label('Review pack') + ->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)), + ]) + ->filters([ + SelectFilter::make('tenant_id') + ->label('Tenant') + ->options(fn (): array => $this->tenantFilterOptions()) + ->default(fn (): ?string => $this->defaultTenantFilter()) + ->query(function (Builder $query, array $data): Builder { + $tenantId = $data['value'] ?? null; + + return is_numeric($tenantId) + ? $query->whereKey((int) $tenantId) + : $query; + }) + ->searchable(), + ]) + ->actions([ + Action::make('open_latest_review') + ->label('Open latest review') + ->icon('heroicon-o-arrow-top-right-on-square') + ->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record)) + ->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview), + Action::make('download_review_pack') + ->label('Download review pack') + ->icon('heroicon-o-arrow-down-tray') + ->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record)) + ->openUrlInNewTab() + ->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))), + ]) + ->bulkActions([]) + ->emptyStateHeading('No entitled tenants match this view') + ->emptyStateDescription(fn (): string => $this->hasActiveFilters() + ? 'Clear the current filters to return to the full customer review workspace for your entitled tenants.' + : 'Adjust filters to return to the full customer review workspace for your entitled tenants.') + ->emptyStateActions([ + Action::make('clear_filters_empty') + ->label('Clear filters') + ->icon('heroicon-o-x-mark') + ->color('gray') + ->visible(fn (): bool => $this->hasActiveFilters()) + ->action(fn (): mixed => $this->clearWorkspaceFilters()), + ]); + } + + /** + * @return array + */ + public function authorizedTenants(): array + { + if ($this->authorizedTenants !== null) { + return $this->authorizedTenants; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->authorizedTenants = []; + } + + return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace); + } + + private function authorizePageAccess(): void + { + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User) { + abort(403); + } + + if (! $workspace instanceof Workspace) { + throw new NotFoundHttpException; + } + + $service = app(TenantReviewRegisterService::class); + + if (! $service->canAccessWorkspace($user, $workspace)) { + throw new NotFoundHttpException; + } + + if ($this->authorizedTenants() === []) { + throw new NotFoundHttpException; + } + } + + private function workspaceQuery(): Builder + { + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return Tenant::query()->whereRaw('1 = 0'); + } + + return app(TenantReviewRegisterService::class)->customerWorkspaceTenantQuery($user, $workspace); + } + + /** + * @return array + */ + private function tenantFilterOptions(): array + { + return collect($this->authorizedTenants()) + ->mapWithKeys(static fn (Tenant $tenant): array => [ + (string) $tenant->getKey() => $tenant->name, + ]) + ->all(); + } + + private function defaultTenantFilter(): ?string + { + $tenantId = app(WorkspaceContext::class)->lastTenantId(request()); + + return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants()) + ? (string) $tenantId + : null; + } + + private function applyRequestedTenantPrefilter(): void + { + $requestedTenant = request()->query('tenant', request()->query('tenant_id')); + + if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) { + return; + } + + foreach ($this->authorizedTenants() 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; + } + + throw new NotFoundHttpException; + } + + private function hasActiveFilters(): bool + { + return $this->currentTenantFilterId() !== null; + } + + private function clearWorkspaceFilters(): void + { + app(WorkspaceContext::class)->clearLastTenantId(request()); + $this->removeTableFilters(); + } + + private function currentTenantFilterId(): ?int + { + $tenantFilter = data_get($this->tableFilters, 'tenant_id.value'); + + if (! is_numeric($tenantFilter)) { + $tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value'); + } + + return is_numeric($tenantFilter) ? (int) $tenantFilter : null; + } + + private function workspace(): ?Workspace + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + return is_numeric($workspaceId) + ? Workspace::query()->whereKey((int) $workspaceId)->first() + : null; + } + + private function latestPublishedReview(Tenant $tenant): ?TenantReview + { + $review = $tenant->tenantReviews->first(); + + return $review instanceof TenantReview ? $review : null; + } + + private function latestReviewUrl(Tenant $tenant): ?string + { + $review = $this->latestPublishedReview($tenant); + + if (! $review instanceof TenantReview) { + return null; + } + + return TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant').'?'.http_build_query([ + self::DETAIL_CONTEXT_QUERY_KEY => 1, + ]); + } + + private function latestReviewPack(Tenant $tenant): ?ReviewPack + { + $review = $this->latestPublishedReview($tenant); + $pack = $review?->currentExportReviewPack; + + return $pack instanceof ReviewPack ? $pack : null; + } + + private function latestReviewPackDownloadUrl(Tenant $tenant): ?string + { + $user = auth()->user(); + $pack = $this->latestReviewPack($tenant); + + if (! $user instanceof User || ! $pack instanceof ReviewPack) { + return null; + } + + if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) { + return null; + } + + if ($pack->status !== ReviewPackStatus::Ready->value) { + return null; + } + + if ($pack->expires_at !== null && $pack->expires_at->isPast()) { + return null; + } + + return app(ReviewPackService::class)->generateDownloadUrl($pack, [ + 'source_surface' => self::SOURCE_SURFACE, + ]); + } + + private function latestPublishedAt(Tenant $tenant): ?\Illuminate\Support\Carbon + { + return $this->latestPublishedReview($tenant)?->published_at; + } + + private function reviewTruth(Tenant $tenant): ?ArtifactTruthEnvelope + { + $review = $this->latestPublishedReview($tenant); + + return $review instanceof TenantReview + ? app(ArtifactTruthPresenter::class)->forTenantReview($review) + : null; + } + + private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome + { + $presenter = app(ArtifactTruthPresenter::class); + $review = $this->latestPublishedReview($tenant); + $truth = $this->reviewTruth($tenant); + + if (! $review instanceof TenantReview || ! $truth instanceof ArtifactTruthEnvelope) { + return null; + } + + return $presenter->compressedOutcomeFor($review, SurfaceCompressionContext::reviewRegister()) + ?? $presenter->compressedOutcomeFromEnvelope($truth, SurfaceCompressionContext::reviewRegister()); + } + + private function latestReviewStateLabel(Tenant $tenant): string + { + return $this->reviewOutcome($tenant)?->primaryLabel ?? 'No published review'; + } + + private function latestReviewStateColor(Tenant $tenant): string + { + return $this->reviewOutcome($tenant)?->primaryBadge->color ?? 'gray'; + } + + private function latestReviewStateIcon(Tenant $tenant): ?string + { + return $this->reviewOutcome($tenant)?->primaryBadge->icon; + } + + private function latestReviewStateIconColor(Tenant $tenant): ?string + { + return $this->reviewOutcome($tenant)?->primaryBadge->iconColor; + } + + private function reviewOutcomeDescription(Tenant $tenant): ?string + { + $review = $this->latestPublishedReview($tenant); + + if (! $review instanceof TenantReview) { + return 'No published review available yet'; + } + + $primaryReason = $this->reviewOutcome($tenant)?->primaryReason; + $summary = is_array($review->summary) ? $review->summary : []; + $findingOutcomes = $summary['finding_outcomes'] ?? null; + + if (! is_array($findingOutcomes)) { + return $primaryReason; + } + + $findingOutcomeSummary = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes); + + if ($findingOutcomeSummary === null) { + return $primaryReason; + } + + return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.'); + } + + private function findingSummary(Tenant $tenant): string + { + $review = $this->latestPublishedReview($tenant); + + if (! $review instanceof TenantReview) { + return 'No published review available yet'; + } + + $summary = is_array($review->summary) ? $review->summary : []; + $findingCount = (int) ($summary['finding_count'] ?? 0); + $findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : []; + $terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes); + + if ($findingCount === 0) { + return 'No findings recorded in the published review.'; + } + + if ($terminalOutcomes === null) { + return sprintf('%d findings summarized in the published review.', $findingCount); + } + + return sprintf('%d findings. Terminal outcomes: %s.', $findingCount, $terminalOutcomes); + } + + private function acceptedRiskSummary(Tenant $tenant): string + { + $review = $this->latestPublishedReview($tenant); + + if (! $review instanceof TenantReview) { + return 'No published review available yet'; + } + + $summary = is_array($review->summary) ? $review->summary : []; + $riskAcceptance = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : []; + $statusMarkedCount = (int) ($riskAcceptance['status_marked_count'] ?? 0); + $validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0); + $warningCount = (int) ($riskAcceptance['warning_count'] ?? 0); + + return match (true) { + $statusMarkedCount === 0 => 'No accepted risks recorded.', + $warningCount > 0 => sprintf('%d accepted risks need governance follow-up (%d total).', $warningCount, $statusMarkedCount), + $validGovernedCount > 0 => sprintf('%d accepted risks are governed.', $validGovernedCount), + default => sprintf('%d accepted risks are on record.', $statusMarkedCount), + }; + } + + private function reviewPackAvailability(Tenant $tenant): string + { + $pack = $this->latestReviewPack($tenant); + + if (! $pack instanceof ReviewPack) { + return 'Unavailable'; + } + + if ($pack->status !== ReviewPackStatus::Ready->value) { + return 'Unavailable'; + } + + if ($pack->expires_at !== null && $pack->expires_at->isPast()) { + return 'Unavailable'; + } + + return 'Available'; + } +} \ No newline at end of file diff --git a/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php b/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php index 1e6b1da7..abfcbb47 100644 --- a/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php +++ b/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php @@ -6,6 +6,7 @@ use App\Filament\Concerns\InteractsWithTenantOwnedRecords; use App\Filament\Concerns\ResolvesPanelTenantContext; +use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Resources\EvidenceSnapshotResource\Pages; use App\Filament\Resources\ReviewPackResource; use App\Models\EvidenceSnapshot; @@ -267,6 +268,20 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array )->toArray(); } + if ($record->tenant instanceof Tenant) { + $entries[] = RelatedContextEntry::available( + key: 'customer_review_workspace', + label: 'Customer workspace', + value: $record->tenant->name, + secondaryValue: 'Open the customer-safe review workspace prefiltered to this tenant.', + targetUrl: CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant), + targetKind: 'canonical_page', + priority: 30, + actionLabel: 'Open customer workspace', + contextBadge: 'Reporting', + )->toArray(); + } + return $entries; } diff --git a/apps/platform/app/Filament/Resources/ReviewPackResource.php b/apps/platform/app/Filament/Resources/ReviewPackResource.php index c3b462ad..86e26d67 100644 --- a/apps/platform/app/Filament/Resources/ReviewPackResource.php +++ b/apps/platform/app/Filament/Resources/ReviewPackResource.php @@ -5,6 +5,7 @@ use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException; use App\Exceptions\ReviewPackEvidenceResolutionException; +use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource; use App\Filament\Resources\ReviewPackResource\Pages; use App\Models\ReviewPack; @@ -195,6 +196,13 @@ public static function infolist(Schema $schema): Schema ? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant) : null) ->placeholder('—'), + TextEntry::make('customer_workspace') + ->label('Customer workspace') + ->state(fn (): string => 'Open workspace') + ->url(fn (ReviewPack $record): ?string => $record->tenant instanceof Tenant + ? CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant) + : null) + ->placeholder('—'), TextEntry::make('summary.review_status') ->label('Review status') ->badge() diff --git a/apps/platform/app/Filament/Resources/TenantReviewResource.php b/apps/platform/app/Filament/Resources/TenantReviewResource.php index 577b78ba..8ae2220e 100644 --- a/apps/platform/app/Filament/Resources/TenantReviewResource.php +++ b/apps/platform/app/Filament/Resources/TenantReviewResource.php @@ -6,6 +6,7 @@ use App\Filament\Concerns\InteractsWithTenantOwnedRecords; use App\Filament\Concerns\ResolvesPanelTenantContext; +use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Resources\TenantReviewResource\Pages; use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException; use App\Models\EvidenceSnapshot; @@ -649,6 +650,15 @@ private static function summaryContextLinks(TenantReview $record): array ]; } + if ($record->tenant) { + $links[] = [ + 'title' => 'Customer workspace', + 'label' => 'Open customer workspace', + 'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant), + 'description' => 'Open the customer-safe review workspace prefiltered to this tenant.', + ]; + } + if ($record->evidenceSnapshot && $record->tenant) { $links[] = [ 'title' => 'Evidence snapshot', diff --git a/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php b/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php index fdd82a88..d6cdd157 100644 --- a/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php +++ b/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php @@ -4,12 +4,15 @@ namespace App\Filament\Resources\TenantReviewResource\Pages; +use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Resources\TenantReviewResource; use App\Models\Tenant; use App\Models\TenantReview; use App\Models\User; +use App\Services\Audit\WorkspaceAuditLogger; use App\Services\TenantReviews\TenantReviewLifecycleService; use App\Services\TenantReviews\TenantReviewService; +use App\Support\Audit\AuditActionId; use App\Support\Auth\Capabilities; use App\Support\Rbac\UiEnforcement; use App\Support\TenantReviewStatus; @@ -24,6 +27,13 @@ class ViewTenantReview extends ViewRecord { protected static string $resource = TenantReviewResource::class; + public function mount(int|string $record): void + { + parent::mount($record); + + $this->auditCustomerWorkspaceOpen(); + } + protected function resolveRecord(int|string $key): Model { return TenantReviewResource::resolveScopedRecordOrFail($key); @@ -69,7 +79,7 @@ protected function getHeaderActions(): array ->label('Danger') ->icon('heroicon-o-archive-box') ->color('danger') - ->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()), + ->visible(fn (): bool => ! $this->isCustomerWorkspaceView() && ! $this->record->statusEnum()->isTerminal()), ])); } @@ -85,6 +95,10 @@ private function primaryLifecycleAction(): ?Actions\Action private function primaryLifecycleActionName(): ?string { + if ($this->isCustomerWorkspaceView()) { + return null; + } + if ((string) $this->record->status === TenantReviewStatus::Published->value) { return 'export_executive_pack'; } @@ -122,6 +136,10 @@ private function secondaryLifecycleActions(): array */ private function secondaryLifecycleActionNames(): array { + if ($this->isCustomerWorkspaceView()) { + return []; + } + $names = []; if ($this->record->isMutable()) { @@ -178,7 +196,6 @@ private function refreshReviewAction(): Actions\Action }), ) ->requireCapability(Capabilities::TENANT_REVIEW_MANAGE) - ->preserveVisibility() ->apply(); } @@ -325,4 +342,39 @@ private function archiveReviewAction(): Actions\Action ->preserveVisibility() ->apply(); } + + private function isCustomerWorkspaceView(): bool + { + return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY); + } + + private function auditCustomerWorkspaceOpen(): void + { + if (! $this->isCustomerWorkspaceView()) { + return; + } + + $user = auth()->user(); + $tenant = $this->record->tenant; + + if (! $user instanceof User || ! $tenant instanceof Tenant) { + return; + } + + app(WorkspaceAuditLogger::class)->log( + workspace: $tenant->workspace, + action: AuditActionId::TenantReviewOpened, + context: [ + 'metadata' => [ + 'review_id' => (int) $this->record->getKey(), + 'source_surface' => 'customer_review_workspace', + ], + ], + actor: $user, + resourceType: 'tenant_review', + resourceId: (string) $this->record->getKey(), + targetLabel: sprintf('Tenant review #%d', (int) $this->record->getKey()), + tenant: $tenant, + ); + } } diff --git a/apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php b/apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php index 2930b9f6..5052d05b 100644 --- a/apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php +++ b/apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php @@ -5,6 +5,7 @@ namespace App\Filament\Widgets\Tenant; use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException; +use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Models\OperationRun; use App\Models\ReviewPack; use App\Models\Tenant; @@ -180,6 +181,7 @@ protected function getViewData(): array 'canManage' => $canManage, 'generationBlocked' => $generationBlocked, 'generationBlockReason' => $generationBlockReason, + 'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null, 'downloadUrl' => null, 'failedReason' => null, 'reviewUrl' => null, @@ -230,6 +232,7 @@ protected function getViewData(): array 'canManage' => $canManage, 'generationBlocked' => $generationBlocked, 'generationBlockReason' => $generationBlockReason, + 'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null, 'downloadUrl' => $downloadUrl, 'failedReason' => $failedReason, 'failedReasonDetail' => $failedReasonDetail, @@ -262,6 +265,7 @@ private function emptyState(): array 'canManage' => false, 'generationBlocked' => false, 'generationBlockReason' => null, + 'customerWorkspaceUrl' => null, 'downloadUrl' => null, 'failedReason' => null, 'failedReasonDetail' => null, diff --git a/apps/platform/app/Http/Controllers/ReviewPackDownloadController.php b/apps/platform/app/Http/Controllers/ReviewPackDownloadController.php index 024b784b..5117cedc 100644 --- a/apps/platform/app/Http/Controllers/ReviewPackDownloadController.php +++ b/apps/platform/app/Http/Controllers/ReviewPackDownloadController.php @@ -4,7 +4,12 @@ namespace App\Http\Controllers; +use App\Models\Tenant; use App\Models\ReviewPack; +use App\Models\User; +use App\Services\Audit\WorkspaceAuditLogger; +use App\Support\Audit\AuditActionId; +use App\Support\Auth\Capabilities; use App\Support\ReviewPackStatus; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; @@ -15,6 +20,21 @@ class ReviewPackDownloadController extends Controller { public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResponse { + $user = $request->user(); + $tenant = $reviewPack->tenant; + + if (! $user instanceof User || ! $tenant instanceof Tenant) { + throw new NotFoundHttpException; + } + + if (! $user->canAccessTenant($tenant)) { + throw new NotFoundHttpException; + } + + if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) { + abort(403); + } + if ($reviewPack->status !== ReviewPackStatus::Ready->value) { throw new NotFoundHttpException; } @@ -29,7 +49,26 @@ public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResp throw new NotFoundHttpException; } - $tenant = $reviewPack->tenant; + app(WorkspaceAuditLogger::class)->log( + workspace: $tenant->workspace, + action: AuditActionId::ReviewPackDownloaded, + context: [ + 'metadata' => [ + 'review_pack_id' => (int) $reviewPack->getKey(), + 'tenant_review_id' => $reviewPack->tenant_review_id !== null + ? (int) $reviewPack->tenant_review_id + : null, + 'source_surface' => (string) $request->query('source_surface', 'review_pack'), + ], + ], + actor: $user, + resourceType: 'review_pack', + resourceId: (string) $reviewPack->getKey(), + targetLabel: sprintf('Review pack #%d', (int) $reviewPack->getKey()), + tenant: $tenant, + operationRunId: $reviewPack->operation_run_id, + ); + $filename = sprintf( 'review-pack-%s-%s.zip', $tenant?->external_id ?? 'unknown', diff --git a/apps/platform/app/Providers/Filament/AdminPanelProvider.php b/apps/platform/app/Providers/Filament/AdminPanelProvider.php index e425e200..3ba79958 100644 --- a/apps/platform/app/Providers/Filament/AdminPanelProvider.php +++ b/apps/platform/app/Providers/Filament/AdminPanelProvider.php @@ -12,6 +12,7 @@ use App\Filament\Pages\Monitoring\FindingExceptionsQueue; use App\Filament\Pages\NoAccess; use App\Filament\Pages\Reviews\ReviewRegister; +use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Pages\Settings\WorkspaceSettings; use App\Filament\Pages\TenantRequiredPermissions; use App\Filament\Pages\WorkspaceOverview; @@ -183,6 +184,7 @@ public function panel(Panel $panel): Panel FindingsIntakeQueue::class, MyFindingsInbox::class, FindingExceptionsQueue::class, + CustomerReviewWorkspace::class, ReviewRegister::class, ]) ->widgets([ diff --git a/apps/platform/app/Services/ReviewPackService.php b/apps/platform/app/Services/ReviewPackService.php index 9d0b3c8b..2ca0e04b 100644 --- a/apps/platform/app/Services/ReviewPackService.php +++ b/apps/platform/app/Services/ReviewPackService.php @@ -234,14 +234,16 @@ public function computeFingerprint(Tenant $tenant, array $options): string /** * Generate a signed download URL for a review pack. + * + * @param array $parameters */ - public function generateDownloadUrl(ReviewPack $pack): string + public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): string { $ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60); return URL::signedRoute( 'admin.review-packs.download', - ['reviewPack' => $pack->getKey()], + array_merge(['reviewPack' => $pack->getKey()], $parameters), now()->addMinutes($ttlMinutes), ); } diff --git a/apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php b/apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php index 2876fe24..8df1f79c 100644 --- a/apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php +++ b/apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php @@ -12,6 +12,7 @@ use App\Services\Auth\RoleCapabilityMap; use App\Support\Auth\Capabilities; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\DB; final class TenantReviewRegisterService { @@ -43,6 +44,55 @@ public function query(User $user, Workspace $workspace): Builder ->latest('id'); } + public function latestPublishedQuery(User $user, Workspace $workspace): Builder + { + $tenantIds = array_keys($this->authorizedTenants($user, $workspace)); + + $rankedReviews = TenantReview::query() + ->select([ + 'tenant_reviews.id', + 'tenant_reviews.tenant_id', + 'tenant_reviews.published_at', + 'tenant_reviews.generated_at', + ]) + ->selectRaw('ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY published_at DESC, generated_at DESC, id DESC) as rn') + ->forWorkspace((int) $workspace->getKey()) + ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) + ->published(); + + $latestPublishedIds = DB::query() + ->fromSub($rankedReviews, 'ranked_tenant_reviews') + ->where('rn', 1) + ->select('id'); + + return TenantReview::query() + ->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack']) + ->forWorkspace((int) $workspace->getKey()) + ->whereIn('tenant_reviews.id', $latestPublishedIds) + ->orderByDesc('published_at') + ->orderByDesc('generated_at') + ->orderByDesc('id'); + } + + public function customerWorkspaceTenantQuery(User $user, Workspace $workspace): Builder + { + $tenantIds = array_keys($this->authorizedTenants($user, $workspace)); + + return Tenant::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->whereIn('id', $tenantIds === [] ? [-1] : $tenantIds) + ->with([ + 'tenantReviews' => fn ($query) => $query + ->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack']) + ->published() + ->orderByDesc('published_at') + ->orderByDesc('generated_at') + ->orderByDesc('id') + ->limit(1), + ]) + ->orderBy('name'); + } + public function canAccessWorkspace(User $user, Workspace $workspace): bool { return WorkspaceMembership::query() diff --git a/apps/platform/app/Support/Audit/AuditActionId.php b/apps/platform/app/Support/Audit/AuditActionId.php index 85d9ce06..1e17bc1b 100644 --- a/apps/platform/app/Support/Audit/AuditActionId.php +++ b/apps/platform/app/Support/Audit/AuditActionId.php @@ -94,8 +94,10 @@ enum AuditActionId: string case TenantReviewRefreshed = 'tenant_review.refreshed'; case TenantReviewPublished = 'tenant_review.published'; case TenantReviewArchived = 'tenant_review.archived'; + case TenantReviewOpened = 'tenant_review.opened'; case TenantReviewExported = 'tenant_review.exported'; case TenantReviewSuccessorCreated = 'tenant_review.successor_created'; + case ReviewPackDownloaded = 'review_pack.downloaded'; case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed'; case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed'; @@ -238,8 +240,10 @@ private static function labels(): array self::TenantReviewRefreshed->value => 'Tenant review refreshed', self::TenantReviewPublished->value => 'Tenant review published', self::TenantReviewArchived->value => 'Tenant review archived', + self::TenantReviewOpened->value => 'Tenant review opened', self::TenantReviewExported->value => 'Tenant review exported', self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created', + self::ReviewPackDownloaded->value => 'Review pack downloaded', self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed', self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed', self::SupportDiagnosticsOpened->value => 'Support diagnostics opened', @@ -328,8 +332,10 @@ private static function summaries(): array self::TenantReviewRefreshed->value => 'Tenant review refreshed', self::TenantReviewPublished->value => 'Tenant review published', self::TenantReviewArchived->value => 'Tenant review archived', + self::TenantReviewOpened->value => 'Tenant review opened', self::TenantReviewExported->value => 'Tenant review exported', self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created', + self::ReviewPackDownloaded->value => 'Review pack downloaded', self::SupportDiagnosticsOpened->value => 'Support diagnostics opened', self::SupportRequestCreated->value => 'Support request created', self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated', diff --git a/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php b/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php new file mode 100644 index 00000000..b9a5f6b2 --- /dev/null +++ b/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php @@ -0,0 +1,19 @@ + + +
+
+ Customer-safe review workspace +
+ +
+ Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context. +
+ +
+ Opening a row returns to the existing tenant review detail so evidence, review packs, and audit-aware proof remain on their canonical tenant-scoped surfaces. +
+
+
+ + {{ $this->table }} +
\ No newline at end of file diff --git a/apps/platform/resources/views/filament/widgets/tenant/tenant-review-pack-card.blade.php b/apps/platform/resources/views/filament/widgets/tenant/tenant-review-pack-card.blade.php index 8cec52bf..f7a0ffda 100644 --- a/apps/platform/resources/views/filament/widgets/tenant/tenant-review-pack-card.blade.php +++ b/apps/platform/resources/views/filament/widgets/tenant/tenant-review-pack-card.blade.php @@ -11,6 +11,7 @@ /** @var bool $canManage */ /** @var bool $generationBlocked */ /** @var ?string $generationBlockReason */ + /** @var ?string $customerWorkspaceUrl */ /** @var ?string $downloadUrl */ /** @var ?string $failedReason */ /** @var ?string $failedReasonDetail */ @@ -215,5 +216,18 @@ @endif
@endif + + @if ($canView && $customerWorkspaceUrl) +
+ + Customer workspace + +
+ @endif diff --git a/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php b/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php new file mode 100644 index 00000000..12925053 --- /dev/null +++ b/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php @@ -0,0 +1,100 @@ +browser()->timeout(20_000); + +beforeEach(function (): void { + Storage::fake('exports'); +}); + +it('smokes the customer review workspace handoff from tenant review detail', function (): void { + $tenantPublished = Tenant::factory()->create(['name' => 'Published Tenant']); + [$user, $tenantPublished] = createUserWithTenant( + tenant: $tenantPublished, + role: 'owner', + workspaceRole: 'manager', + ); + + $tenantWithoutPublished = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantPublished->workspace_id, + 'name' => 'No Published Tenant', + ]); + + createUserWithTenant( + tenant: $tenantWithoutPublished, + user: $user, + role: 'owner', + workspaceRole: 'manager', + ); + + $publishedSnapshot = seedTenantReviewEvidence($tenantPublished); + $noPublishedSnapshot = seedTenantReviewEvidence($tenantWithoutPublished); + + $publishedReview = composeTenantReviewForTest($tenantPublished, $user, $publishedSnapshot); + $publishedReview->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $internalOnlyReview = composeTenantReviewForTest($tenantWithoutPublished, $user, $noPublishedSnapshot); + $internalOnlyReview->forceFill([ + 'status' => TenantReviewStatus::Ready->value, + 'published_at' => null, + 'published_by_user_id' => null, + ])->save(); + + Storage::disk('exports')->put('review-packs/customer-review-workspace-smoke.zip', 'PK-test'); + + ReviewPack::factory()->ready()->create([ + 'tenant_id' => (int) $tenantPublished->getKey(), + 'workspace_id' => (int) $tenantPublished->workspace_id, + 'tenant_review_id' => (int) $publishedReview->getKey(), + 'evidence_snapshot_id' => (int) $publishedSnapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'file_path' => 'review-packs/customer-review-workspace-smoke.zip', + 'file_disk' => 'exports', + ]); + + $this->actingAs($user)->withSession([ + WorkspaceContext::SESSION_KEY => (int) $tenantPublished->workspace_id, + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [ + (string) $tenantPublished->workspace_id => (int) $tenantPublished->getKey(), + ], + ]); + + visit(TenantReviewResource::tenantScopedUrl('view', ['record' => $publishedReview], $tenantPublished)) + ->waitForText('Related context') + ->assertSee('Open customer workspace') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs() + ->click('Open customer workspace') + ->waitForText('Customer-safe review workspace') + ->assertSee('Clear filters') + ->assertSee('Open latest review') + ->assertDontSee('Publish review') + ->assertDontSee('Refresh review') + ->click('Clear filters') + ->waitForText('No published review available yet') + ->assertSee('No published review available yet') + ->click('Open latest review') + ->waitForText('Outcome summary') + ->assertDontSee('Publish review') + ->assertDontSee('Refresh review') + ->assertDontSee('Create next review') + ->assertDontSee('Export executive pack') + ->assertDontSee('Archive review') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php b/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php index f0aaec7a..21aac9dd 100644 --- a/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php +++ b/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php @@ -3,7 +3,9 @@ declare(strict_types=1); use App\Models\ReviewPack; +use App\Models\AuditLog; use App\Services\ReviewPackService; +use App\Support\Audit\AuditActionId; use App\Support\ReviewPackStatus; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Storage; @@ -41,13 +43,25 @@ function createReadyPackWithFile(?array $packOverrides = []): array it('downloads a ready pack via signed URL with correct headers', function (): void { [$user, $tenant, $pack] = createReadyPackWithFile(); - $signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack); + $signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [ + 'source_surface' => 'customer_review_workspace', + ]); $response = $this->actingAs($user)->get($signedUrl); $response->assertOk(); $response->assertHeader('X-Review-Pack-SHA256', $pack->sha256); $response->assertDownload(); + + $audit = AuditLog::query() + ->where('action', AuditActionId::ReviewPackDownloaded->value) + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull() + ->and($audit?->resource_type)->toBe('review_pack') + ->and(data_get($audit?->metadata, 'review_pack_id'))->toBe((int) $pack->getKey()) + ->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace'); }); // ─── Expired Signature → 403 ──────────────────────────────── diff --git a/apps/platform/tests/Feature/ReviewPack/ReviewPackRbacTest.php b/apps/platform/tests/Feature/ReviewPack/ReviewPackRbacTest.php index 251cd848..72fead90 100644 --- a/apps/platform/tests/Feature/ReviewPack/ReviewPackRbacTest.php +++ b/apps/platform/tests/Feature/ReviewPack/ReviewPackRbacTest.php @@ -77,11 +77,9 @@ function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ? 'file_disk' => 'exports', ]); - // Note: download route uses signed middleware, not tenant-scoped RBAC. - // Any user with a valid signature can download. This is by design. $signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack); - $this->actingAs($user)->get($signedUrl)->assertOk(); + $this->actingAs($user)->get($signedUrl)->assertNotFound(); }); // ─── REVIEW_PACK_VIEW Member ──────────────────────────────── diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php new file mode 100644 index 00000000..6bf72b60 --- /dev/null +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php @@ -0,0 +1,66 @@ +create(); + $user = User::factory()->create(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(CustomerReviewWorkspace::getUrl(panel: 'admin')) + ->assertNotFound(); +}); + +it('returns 404 for workspace members that have no tenant review visibility in the active workspace', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(CustomerReviewWorkspace::getUrl(panel: 'admin')) + ->assertNotFound(); +}); + +it('allows entitled workspace members to access the customer review workspace', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(CustomerReviewWorkspace::getUrl(panel: 'admin')) + ->assertOk(); +}); + +it('returns 404 for explicit out-of-scope tenant targeting on the customer review workspace', function (): void { + $tenantAllowed = Tenant::factory()->create(['name' => 'Allowed Tenant']); + [$user, $tenantAllowed] = createUserWithTenant(tenant: $tenantAllowed, role: 'readonly'); + + $tenantDenied = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantAllowed->workspace_id, + 'name' => 'Denied Tenant', + ]); + $otherOwner = User::factory()->create(); + createUserWithTenant(tenant: $tenantDenied, user: $otherOwner, role: 'owner'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantAllowed->workspace_id]) + ->get(CustomerReviewWorkspace::getUrl(panel: 'admin').'?tenant='.(string) $tenantDenied->getKey()) + ->assertNotFound(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php new file mode 100644 index 00000000..ad752915 --- /dev/null +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php @@ -0,0 +1,160 @@ +create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $snapshot = seedTenantReviewEvidence($tenant); + + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $review->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $this->actingAs($user) + ->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)) + ->assertOk() + ->assertSee(CustomerReviewWorkspace::tenantPrefilterUrl($tenant), false); +}); + +it('adds a customer workspace entry to evidence snapshot related context', function (): void { + $tenant = Tenant::factory()->create(); + + $snapshot = EvidenceSnapshot::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => EvidenceSnapshotStatus::Active->value, + 'summary' => [], + 'generated_at' => now(), + ]); + + $entry = collect(EvidenceSnapshotResource::relatedContextEntries($snapshot)) + ->firstWhere('key', 'customer_review_workspace'); + + expect($entry)->not->toBeNull() + ->and($entry['targetUrl'] ?? null)->toBe(CustomerReviewWorkspace::tenantPrefilterUrl($tenant)); +}); + +it('renders a customer workspace link from review pack detail context', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $snapshot = seedTenantReviewEvidence($tenant); + + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $review->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + Storage::disk('exports')->put('review-packs/customer-workspace-link.zip', 'PK-test'); + + $pack = ReviewPack::factory()->ready()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'file_path' => 'review-packs/customer-workspace-link.zip', + 'file_disk' => 'exports', + ]); + + $this->actingAs($user) + ->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'tenant')) + ->assertOk() + ->assertSee(CustomerReviewWorkspace::tenantPrefilterUrl($tenant), false); +}); + +it('renders a customer workspace launch button on the tenant review pack widget', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $snapshot = seedTenantReviewEvidence($tenant); + + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $review->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + Storage::disk('exports')->put('review-packs/widget-customer-workspace.zip', 'PK-test'); + + ReviewPack::factory()->ready()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'file_path' => 'review-packs/widget-customer-workspace.zip', + 'file_disk' => 'exports', + ]); + + setTenantPanelContext($tenant); + + Livewire::actingAs($user) + ->test(TenantReviewPackCard::class, ['record' => $tenant]) + ->assertSee('Customer workspace') + ->assertSee(CustomerReviewWorkspace::tenantPrefilterUrl($tenant), false); +}); + +it('keeps the linked tenant review detail read-only for a readonly-capable actor', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + $snapshot = seedTenantReviewEvidence($tenant); + + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $review->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + setTenantPanelContext($tenant); + + Livewire::withQueryParams([CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1]) + ->actingAs($user) + ->test(ViewTenantReview::class, ['record' => $review->getKey()]) + ->assertSee('Outcome summary') + ->assertActionDoesNotExist('publish_review') + ->assertActionDoesNotExist('refresh_review') + ->assertActionDoesNotExist('create_next_review') + ->assertActionDoesNotExist('export_executive_pack') + ->assertActionHidden('archive_review'); + + $audit = AuditLog::query() + ->where('action', AuditActionId::TenantReviewOpened->value) + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull() + ->and($audit?->resource_type)->toBe('tenant_review') + ->and(data_get($audit?->metadata, 'review_id'))->toBe((int) $review->getKey()) + ->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace'); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php new file mode 100644 index 00000000..3fc30c6d --- /dev/null +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php @@ -0,0 +1,97 @@ +create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + $snapshot = seedTenantReviewEvidence($tenant); + + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $review->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $pack = ReviewPack::factory()->ready()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'expires_at' => now()->addDay(), + ]); + + $review->forceFill([ + 'current_export_review_pack_id' => (int) $pack->getKey(), + ])->save(); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + Livewire::actingAs($user) + ->test(CustomerReviewWorkspace::class) + ->assertTableActionVisible('open_latest_review', $tenant) + ->assertTableActionVisible('download_review_pack', $tenant) + ->assertSee('Available'); +}); + +it('shows an unavailable pack state and hides the download action when no current review pack exists', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + $snapshot = seedTenantReviewEvidence($tenant); + + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $review->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + 'current_export_review_pack_id' => null, + ])->save(); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + Livewire::actingAs($user) + ->test(CustomerReviewWorkspace::class) + ->assertTableActionVisible('open_latest_review', $tenant) + ->assertTableActionHidden('download_review_pack', $tenant) + ->assertSee('Unavailable'); +}); + +it('hides review and pack actions for tenants without a published review', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + $snapshot = seedTenantReviewEvidence($tenant); + + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $review->forceFill([ + 'status' => TenantReviewStatus::Ready->value, + 'published_at' => null, + 'published_by_user_id' => null, + 'current_export_review_pack_id' => null, + ])->save(); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + Livewire::actingAs($user) + ->test(CustomerReviewWorkspace::class) + ->assertTableActionHidden('open_latest_review', $tenant) + ->assertTableActionHidden('download_review_pack', $tenant) + ->assertSee('No published review available yet'); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php new file mode 100644 index 00000000..d555c0db --- /dev/null +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php @@ -0,0 +1,222 @@ +create(['name' => 'Alpha Tenant']); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly'); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + 'name' => 'Beta Tenant', + ]); + createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly'); + + $tenantDenied = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + 'name' => 'Denied Tenant', + ]); + $otherOwner = User::factory()->create(); + createUserWithTenant(tenant: $tenantDenied, user: $otherOwner, role: 'owner'); + + $tenantASnapshot = seedTenantReviewEvidence($tenantA); + $tenantBSnapshot = seedTenantReviewEvidence($tenantB); + $tenantDeniedSnapshot = seedTenantReviewEvidence($tenantDenied); + + $olderPublishedReview = composeTenantReviewForTest($tenantA, $user, $tenantASnapshot); + $olderPublishedReview->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'generated_at' => now()->subDays(3), + 'published_at' => now()->subDays(3), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $newerInternalReview = $olderPublishedReview->replicate(); + $newerInternalReview->forceFill([ + 'tenant_id' => (int) $tenantA->getKey(), + 'workspace_id' => (int) $tenantA->workspace_id, + 'evidence_snapshot_id' => (int) $tenantASnapshot->getKey(), + 'status' => TenantReviewStatus::Ready->value, + 'generated_at' => now()->subDay(), + 'published_at' => null, + 'published_by_user_id' => null, + ])->save(); + + $latestPublishedReview = $olderPublishedReview->replicate(); + $latestPublishedReview->forceFill([ + 'tenant_id' => (int) $tenantA->getKey(), + 'workspace_id' => (int) $tenantA->workspace_id, + 'evidence_snapshot_id' => (int) $tenantASnapshot->getKey(), + 'status' => TenantReviewStatus::Published->value, + 'generated_at' => now(), + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $betaPublishedReview = composeTenantReviewForTest($tenantB, $user, $tenantBSnapshot); + $betaPublishedReview->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'generated_at' => now()->subHours(2), + 'published_at' => now()->subHours(2), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $deniedPublishedReview = composeTenantReviewForTest($tenantDenied, $otherOwner, $tenantDeniedSnapshot); + $deniedPublishedReview->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'generated_at' => now()->subHours(3), + 'published_at' => now()->subHours(3), + 'published_by_user_id' => (int) $otherOwner->getKey(), + ])->save(); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); + + Livewire::actingAs($user) + ->test(CustomerReviewWorkspace::class) + ->assertCanSeeTableRecords([$tenantA->fresh(), $tenantB->fresh()]) + ->assertCanNotSeeTableRecords([$tenantDenied->fresh()]) + ->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview->fresh()], $tenantA), false) + ->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $betaPublishedReview->fresh()], $tenantB), false) + ->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $olderPublishedReview->fresh()], $tenantA), false) + ->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $newerInternalReview->fresh()], $tenantA), false) + ->assertDontSee('Publish review') + ->assertDontSee('Refresh review') + ->assertDontSee('Create next review') + ->assertDontSee('Regenerate') + ->assertDontSee('Expire snapshot') + ->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $deniedPublishedReview->fresh()], $tenantDenied), false); +}); + +it('shows entitled tenants without a published review as calm absence rows', function (): void { + $tenantPublished = Tenant::factory()->create(['name' => 'Published Tenant']); + [$user, $tenantPublished] = createUserWithTenant(tenant: $tenantPublished, role: 'readonly'); + + $tenantWithoutPublished = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantPublished->workspace_id, + 'name' => 'No Published Tenant', + ]); + createUserWithTenant(tenant: $tenantWithoutPublished, user: $user, role: 'readonly'); + + $publishedSnapshot = seedTenantReviewEvidence($tenantPublished); + $noPublishedSnapshot = seedTenantReviewEvidence($tenantWithoutPublished); + + $publishedReview = composeTenantReviewForTest($tenantPublished, $user, $publishedSnapshot); + $publishedReview->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now()->subHour(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $internalOnlyReview = composeTenantReviewForTest($tenantWithoutPublished, $user, $noPublishedSnapshot); + $internalOnlyReview->forceFill([ + 'status' => TenantReviewStatus::Ready->value, + 'published_at' => null, + 'published_by_user_id' => null, + ])->save(); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantPublished->workspace_id); + + Livewire::actingAs($user) + ->test(CustomerReviewWorkspace::class) + ->assertCanSeeTableRecords([$tenantPublished->fresh(), $tenantWithoutPublished->fresh()]) + ->assertSee('No published review') + ->assertSee('No published review available yet') + ->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $internalOnlyReview->fresh()], $tenantWithoutPublished), false) + ->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $publishedReview->fresh()], $tenantPublished), false); +}); + +it('defaults the customer review workspace to the remembered tenant when tenant context is available', function (): void { + $tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly'); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + 'name' => 'Beta Tenant', + ]); + createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly'); + + $snapshotA = seedTenantReviewEvidence($tenantA); + $snapshotB = seedTenantReviewEvidence($tenantB); + + $reviewA = composeTenantReviewForTest($tenantA, $user, $snapshotA); + $reviewA->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now()->subDay(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $reviewB = composeTenantReviewForTest($tenantB, $user, $snapshotB); + $reviewB->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenantA->workspace_id => (int) $tenantB->getKey(), + ]); + + Livewire::actingAs($user) + ->test(CustomerReviewWorkspace::class) + ->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey()) + ->filterTable('tenant_id', (string) $tenantB->getKey()) + ->assertCanSeeTableRecords([$tenantB->fresh()]) + ->assertCanNotSeeTableRecords([$tenantA->fresh()]); +}); + +it('prefilters the customer review workspace from an explicit tenant query parameter and accepts external tenant identifiers', function (): void { + $tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly'); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + 'name' => 'Beta Tenant', + ]); + createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly'); + + $snapshotA = seedTenantReviewEvidence($tenantA); + $snapshotB = seedTenantReviewEvidence($tenantB); + + $reviewA = composeTenantReviewForTest($tenantA, $user, $snapshotA); + $reviewA->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $reviewB = composeTenantReviewForTest($tenantB, $user, $snapshotB); + $reviewB->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now()->subDay(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); + + Livewire::withQueryParams(['tenant' => (string) $tenantA->external_id]) + ->test(CustomerReviewWorkspace::class) + ->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey()) + ->filterTable('tenant_id', (string) $tenantA->getKey()) + ->assertCanSeeTableRecords([$tenantA->fresh()]) + ->assertCanNotSeeTableRecords([$tenantB->fresh()]); +}); \ No newline at end of file diff --git a/specs/249-customer-review-workspace/checklists/requirements.md b/specs/249-customer-review-workspace/checklists/requirements.md new file mode 100644 index 00000000..540cf258 --- /dev/null +++ b/specs/249-customer-review-workspace/checklists/requirements.md @@ -0,0 +1,72 @@ +# Preparation Review Checklist: Customer Review Workspace v1 + +**Purpose**: Validate the customer-safe review-consumption package against the repo's guardrail, disclosure, shared-family, and close-out workflow before implementation +**Created**: 2026-04-27 +**Feature**: [spec.md](../spec.md) + +## Applicability And Low-Impact Gate + +- [x] CHK001 The package explicitly treats this as an operator-facing and read-only/customer-safe surface change, so the low-impact `N/A` path is not used. +- [x] CHK002 The spec, plan, and tasks carry the same native/shared-primitives-first classification, shared-family relevance, state ownership, and close-out targeting without inventing second wording. + +## Native, Shared-Family, And State Ownership + +- [x] CHK003 The primary surface remains a native Filament page that composes existing review, pack, and evidence viewers instead of introducing a fake-native shell or standalone customer portal. +- [x] CHK004 Shared detail families remain shared: tenant review, review pack, and evidence detail stay on their existing resource routes, while the new page stays a calm entry point rather than a parallel viewer family. +- [x] CHK005 Shell, page, and URL/query state owners are named once, and the package does not collapse them into new persisted customer-review state. +- [x] CHK006 The likely next operator action and primary inspect/open model stay coherent: `Open latest review` is primary, `Download review pack` is the only safe inline shortcut, and deeper proof stays secondary. + +## Shared Pattern Reuse + +- [x] CHK007 Cross-cutting interaction classes are explicit, and the shared reuse path is named once through `TenantReviewRegisterService`, existing resource URL helpers, `ArtifactTruthPresenter`, `ReviewPackService`, `RedactionIntegrity`, and the audit pipeline. +- [x] CHK008 The package extends existing shared paths where they are sufficient, and any fallback to a bounded page-local read helper or additive audit action is explicitly constrained as a last resort rather than a new default abstraction. +- [x] CHK009 The package does not create a parallel customer-review UX language; it reuses current artifact-truth, publication-readiness, review-pack, and redaction vocabulary. + +## OperationRun Start UX Contract + +- [x] CHK019 The package explicitly states that the new page does not create, queue, deduplicate, resume, block, complete, or deep-link to an `OperationRun` as a primary workflow. +- [x] CHK020 Any existing `OperationRun` links remain on reused detail surfaces, so queued toast/link/browser-event/dedupe behavior is not reimplemented on the customer workspace page. +- [x] CHK021 No queued DB notification behavior or terminal notification path is added because the slice stays read-only and never starts a run. +- [x] CHK022 No OperationRun exception is required; if implementation later promotes run-oriented behavior onto the page, that deviation must be recorded in the active close-out entry before merge. + +## Provider Boundary And Vocabulary + +- [x] CHK010 The package keeps provider-specific semantics behind existing normalized review, evidence, and artifact-truth seams and does not spread provider language into a new platform-core contract. +- [x] CHK011 No retained provider-specific shared boundary is introduced; the slice stays within current workspace, tenant, review, evidence, review-pack, accepted-risk, and audit vocabulary. + +## Signals, Exceptions, And Test Depth + +- [x] CHK012 The triggered repository signal is explicitly handled as `review-mandatory`, with no hidden hard-stop drift accepted into the package. +- [x] CHK013 No bounded exception is required in the preparation package; if implementation discovers a local read helper or additive audit action is unavoidable, that exception must be documented in the active feature close-out entry instead of becoming silent spread. +- [x] CHK014 The required surface test profile is explicit: `standard-native-filament` for the page plus `shared-detail-family` for navigation into existing review, pack, and evidence detail surfaces. +- [x] CHK015 The chosen lane mix is the narrowest honest proof for this disclosure-heavy slice: focused Feature coverage plus one bounded Browser smoke, with optional Unit coverage only if a small read helper is extracted. + +## Audience-Aware Disclosure And Decision Hierarchy + +- [x] CHK023 Default-visible content stays decision-first and clearly separated from deeper diagnostics and support/raw evidence. +- [x] CHK024 The read-only/customer-safe default path does not expose raw JSON, copied payloads, fingerprints, internal reason ownership, platform-debug semantics, or unrestricted audit detail by default. +- [x] CHK025 Exactly one dominant next action is primary: `Open latest review`; safe artifact download remains secondary and does not compete at equal weight. +- [x] CHK026 Duplicate visible blocker, status, or next-action summaries are avoided by reusing one artifact-truth summary per row and leaving detailed proof to the existing detail surfaces. +- [x] CHK027 Support/raw sections remain hidden or capability-gated through reused detail routes only, and the page keeps Filament visual language, progressive disclosure, and calm read-only presentation. + +## Review Outcome + +- [x] CHK016 Review outcome class: `acceptable-special-case` +- [x] CHK017 Workflow outcome: `keep` +- [x] CHK018 The final note location is explicit: the active feature PR close-out entry `Guardrail / Exception / Smoke Coverage` records the guardrail result, smoke outcome, and any bounded implementation exception. + +## Notes + +- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and the supporting design artifacts. It does not claim application code already exists. +- The slice remains bounded to one read-only customer-safe workspace surface in the current admin plane. No new identity plane, persistence layer, review-generation workflow, remediation path, or raw-diagnostic default path is approved by this package. +- If implementation later proves `TenantReviewRegisterService` reuse insufficient or shows that explicit artifact access requires a new stable `AuditActionId`, that must be recorded as a bounded note in `Guardrail / Exception / Smoke Coverage` rather than silently widening the architecture. + +## Implementation Close-out Addendum + +- Implemented surface: native `CustomerReviewWorkspace` page and Blade view in the existing admin-plane reviews family, still reusing current tenant review, review-pack, evidence, artifact-truth, RBAC, and audit seams. +- T010 outcome: direct workspace links landed on tenant review detail, review-pack detail, evidence related context, and the tenant review-pack widget. `ReviewRegister` and `EvidenceOverview` remained acceptable reuse via existing row/detail navigation. +- T020 outcome: pack-download plumbing changed, so `ReviewPackDownloadTest.php` and `ReviewPackRbacTest.php` were updated and passed after request-time membership plus `REVIEW_PACK_VIEW` enforcement was added to the signed download route. +- T023 outcome: the current audit infrastructure was reused with additive `tenant_review.opened` and `review_pack.downloaded` action IDs. No new audit store was introduced. +- Smoke evidence outcome: the implementation close-out used the bounded Pest browser smoke plus the focused feature lane as executed smoke proof. No separate manual integrated-browser run was completed. +- Final review outcome class: `acceptable-special-case`. +- Final workflow outcome: `keep`. \ No newline at end of file diff --git a/specs/249-customer-review-workspace/contracts/customer-review-workspace.openapi.yaml b/specs/249-customer-review-workspace/contracts/customer-review-workspace.openapi.yaml new file mode 100644 index 00000000..62511dd0 --- /dev/null +++ b/specs/249-customer-review-workspace/contracts/customer-review-workspace.openapi.yaml @@ -0,0 +1,261 @@ +openapi: 3.0.3 +info: + title: TenantPilot Customer Review Workspace v1 (Conceptual) + version: 0.1.0 + description: | + Conceptual contract for the read-oriented customer-safe workspace review + surface planned by Spec 249. + + NOTE: The canonical page is planned as a native Filament / Livewire page in + the existing admin plane. The JSON response shapes below describe the + derived workspace view model for planning purposes; they do not require a + new public REST API in v1. +servers: + - url: / +paths: + /admin/reviews/workspace: + get: + summary: View the customer review workspace + description: | + Canonical admin-plane workspace page for customer-safe review + consumption. The page stays read-only and derives its rows from + existing tenant review, review-pack, evidence, and entitlement truth. + parameters: + - in: query + name: tenant_id + required: false + schema: + type: integer + description: | + Optional tenant prefilter carried from an existing tenant-scoped + review, review-pack, evidence, or dashboard entry point. + - in: query + name: source + required: false + schema: + type: string + description: Optional launch-context hint used for page highlighting only. + responses: + '200': + description: Workspace page rendered + content: + text/html: + schema: + type: string + application/json: + schema: + $ref: '#/components/schemas/CustomerReviewWorkspaceCollection' + '403': + description: Forbidden after workspace membership is established but required capability is missing + '404': + description: Not found for non-members or explicit out-of-scope tenant targeting + + /admin/t/{tenant}/reviews/{review}: + get: + summary: Open the latest tenant review detail + description: | + Existing tenant-scoped review detail route reused as the primary inspect + action from the workspace page. + parameters: + - in: path + name: tenant + required: true + schema: + type: integer + - in: path + name: review + required: true + schema: + type: integer + responses: + '200': + description: Tenant review detail rendered + content: + text/html: + schema: + type: string + '403': + description: Forbidden for an entitled member missing the review capability + '404': + description: Not found for non-members or tenant / review mismatches + + /admin/t/{tenant}/evidence/{evidenceSnapshot}: + get: + summary: Open evidence detail from the customer review flow + description: | + Existing tenant-scoped evidence detail route reused only as optional + proof when the actor explicitly asks for it and has the required + capability. + parameters: + - in: path + name: tenant + required: true + schema: + type: integer + - in: path + name: evidenceSnapshot + required: true + schema: + type: integer + responses: + '200': + description: Evidence detail rendered + content: + text/html: + schema: + type: string + '403': + description: Forbidden for an entitled member missing the evidence capability + '404': + description: Not found for non-members or tenant / evidence mismatches + + /admin/review-packs/{reviewPack}/download: + get: + summary: Download the current review pack + description: | + Existing signed download route reused by the workspace page. The pack + must already exist, be ready, and not be expired. + parameters: + - in: path + name: reviewPack + required: true + schema: + type: integer + responses: + '200': + description: Review pack download stream + content: + application/zip: + schema: + type: string + format: binary + '403': + description: Forbidden due to missing signature or invalid signed URL + '404': + description: Review pack not found, not ready, or expired + +components: + schemas: + CustomerReviewWorkspaceCollection: + type: object + required: + - workspace_id + - entries + properties: + workspace_id: + type: integer + tenant_prefilter_id: + type: integer + nullable: true + highlighted_tenant_id: + type: integer + nullable: true + launch_source: + type: string + nullable: true + entries: + type: array + items: + $ref: '#/components/schemas/CustomerReviewWorkspaceEntry' + empty_state_message: + type: string + nullable: true + + CustomerReviewWorkspaceEntry: + type: object + required: + - tenant_id + - tenant_name + - review_pack_available + properties: + tenant_id: + type: integer + tenant_name: + type: string + latest_published_review_id: + type: integer + nullable: true + latest_review_generated_at: + type: string + format: date-time + nullable: true + latest_review_published_at: + type: string + format: date-time + nullable: true + review_outcome_label: + type: string + nullable: true + review_outcome_explanation: + type: string + nullable: true + key_findings_summary: + $ref: '#/components/schemas/FindingsSummary' + accepted_risk_summary: + $ref: '#/components/schemas/AcceptedRiskSummary' + review_pack: + $ref: '#/components/schemas/ReviewPackAccess' + evidence_proof: + $ref: '#/components/schemas/EvidenceProof' + primary_review_url: + type: string + nullable: true + redaction_note: + type: string + nullable: true + absence_note: + type: string + nullable: true + + FindingsSummary: + type: object + nullable: true + properties: + total_visible: + type: integer + attention_required_count: + type: integer + summary_text: + type: string + + AcceptedRiskSummary: + type: object + nullable: true + properties: + accepted_count: + type: integer + follow_up_required_count: + type: integer + summary_text: + type: string + + ReviewPackAccess: + type: object + required: + - available + properties: + available: + type: boolean + review_pack_id: + type: integer + nullable: true + download_url: + type: string + nullable: true + status_message: + type: string + nullable: true + + EvidenceProof: + type: object + nullable: true + properties: + evidence_snapshot_id: + type: integer + nullable: true + detail_url: + type: string + nullable: true + freshness_label: + type: string + nullable: true \ No newline at end of file diff --git a/specs/249-customer-review-workspace/data-model.md b/specs/249-customer-review-workspace/data-model.md new file mode 100644 index 00000000..9be6e1af --- /dev/null +++ b/specs/249-customer-review-workspace/data-model.md @@ -0,0 +1,210 @@ +# Data Model — Customer Review Workspace v1 + +**Spec**: [spec.md](spec.md) + +No new persisted tables or customer-review entities are required for v1. The feature reuses existing tenant-owned review, review-pack, evidence, findings, and audit truth, then derives one workspace-scoped read model for page presentation. + +## Persisted Truth Reused + +### Workspace / Tenant Entitlement Context + +**Purpose**: Establish the current workspace boundary and the entitled tenant set before any review rows are composed. + +**Persisted carriers**: +- existing workspace membership records +- existing tenant membership pivot records and tenant role assignments +- existing capability registry and role-capability map + +**Relevant fields / contracts**: +- `workspace_id` +- `tenant_id` +- tenant membership role +- capability grants derived from [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php) + +**Validation rules**: +- current actor must be a member of the current workspace or the page resolves as not found +- tenant rows may only be composed for tenants in the current workspace where the actor is entitled through the canonical role-capability map +- no cross-workspace or cross-tenant fallback lookups are allowed + +### TenantReview + +**Purpose**: Canonical source for the latest customer-safe review posture, summary text, findings summary, accepted-risk summary, and primary inspect target. + +**Persisted carrier**: existing `tenant_reviews` rows via `TenantReview` + +**Relevant fields / relationships**: +- `id` +- `workspace_id` +- `tenant_id` +- `status` +- `generated_at` +- `published_at` +- `summary` +- `evidence_snapshot_id` +- `current_export_review_pack_id` +- `tenant` +- `evidenceSnapshot` +- `currentExportReviewPack` +- `sections` + +**Validation / usage rules**: +- the default customer-safe path uses the latest published review per entitled tenant +- draft, ready, failed, archived, and superseded reviews stay off the default-visible page summary unless explicitly reused as internal proof elsewhere +- summary data already shaped into the review artifact remains the preferred source for findings and review-level posture messaging + +### TenantReviewSection + +**Purpose**: Supporting persisted proof for accepted-risk and section-level disclosure without introducing a new workspace summary store. + +**Persisted carrier**: existing `tenant_review_sections` rows + +**Relevant fields / relationships**: +- `tenant_review_id` +- `section_key` +- `title` +- `completeness_state` +- `summary_payload` +- `render_payload` + +**Validation / usage rules**: +- accepted-risk summaries should come from the existing review section payloads that were already composed for the review artifact +- section payload reuse must remain read-only and redaction-safe + +### ReviewPack + +**Purpose**: Canonical packaged artifact for customer-safe review consumption and download. + +**Persisted carrier**: existing `review_packs` rows via `ReviewPack` + +**Relevant fields / relationships**: +- `id` +- `workspace_id` +- `tenant_id` +- `tenant_review_id` +- `status` +- `generated_at` +- `expires_at` +- `summary` +- `file_path` +- `file_disk` +- `sha256` +- `tenantReview` +- `evidenceSnapshot` + +**Validation / usage rules**: +- download is available only when the current pack is ready and not expired +- the workspace page may surface availability and a signed download path only when `REVIEW_PACK_VIEW` applies +- the workspace page must not start generation, regeneration, or recovery flows + +### EvidenceSnapshot + +**Purpose**: Existing proof artifact for freshness and evidence completeness when the actor explicitly asks for supporting detail. + +**Persisted carrier**: existing `evidence_snapshots` rows via `EvidenceSnapshot` + +**Relevant fields / relationships**: +- `id` +- `workspace_id` +- `tenant_id` +- `status` +- `completeness_state` +- `generated_at` +- `expires_at` +- `summary` +- `items` + +**Validation / usage rules**: +- evidence detail is not part of the default-visible customer path +- drilldown remains explicit and capability-gated by `EVIDENCE_VIEW` +- evidence truth remains tenant-owned and derived from the existing snapshot lifecycle + +### Audit Log Event Family + +**Purpose**: Existing audit truth for explicit review-artifact access and download actions. + +**Persisted carrier**: existing `audit_logs` rows via `WorkspaceAuditLogger` + +**Relevant fields / contracts**: +- stable `AuditActionId` +- `workspace_id` +- optional `tenant_id` +- actor metadata +- target resource type / id / label +- action context metadata + +**Validation / usage rules**: +- no new audit store is introduced +- explicit artifact open/download events should reuse the current audit pipeline +- page render itself should not become a noisy new audit family + +## Derived Read Model + +### CustomerReviewWorkspaceEntry + +**Purpose**: Derived page row or card summarizing the latest customer-safe review state for one entitled tenant. + +**Persistence**: none; computed at request time + +**Fields**: +- `workspace_id` +- `tenant_id` +- `tenant_name` +- `latest_published_review_id` (nullable) +- `latest_review_generated_at` (nullable) +- `latest_review_published_at` (nullable) +- `review_outcome_label` (nullable, derived from existing artifact truth) +- `review_outcome_explanation` (nullable) +- `key_findings_summary` (nullable, derived from existing review summary) +- `accepted_risk_summary` (nullable, derived from existing review section payloads) +- `review_pack_id` (nullable) +- `review_pack_available` (boolean) +- `review_pack_status_note` (nullable derived string) +- `evidence_snapshot_id` (nullable) +- `primary_review_url` (nullable) +- `review_pack_download_url` (nullable) +- `evidence_detail_url` (nullable) +- `redaction_note` (nullable) +- `absence_note` (nullable derived string) + +**Derivation rules**: +- exactly one entry exists per entitled tenant visible in the current workspace scope +- when a published review exists, the entry derives customer-safe posture from that latest published review only +- when no published review exists for an entitled tenant, the entry may carry a derived absence note such as `No published review available yet`; this remains view logic, not domain state +- raw JSON, raw provider payloads, unrestricted audit metadata, fingerprints, and debug-only context are never part of the entry + +**Validation rules**: +- entries may only be built for tenants in the current workspace and current entitlement scope +- `review_pack_download_url` is present only when a current pack exists and the actor can view it +- `evidence_detail_url` is present only when the actor can view evidence detail +- absence or unavailable wording must not hint at hidden drafts or hidden operator-only artifacts + +## Request-Scoped Page State + +### CustomerReviewWorkspaceState + +**Purpose**: Livewire-safe page state carrying tenant launch context and remembered filters. + +**Persistence**: request, query, and session-backed page state only + +**Fields**: +- `tenant_id` (nullable requested prefilter) +- `highlight_tenant_id` (nullable) +- `launch_source` (nullable string such as review, review_pack, evidence, or dashboard) +- `search` (nullable) +- `tableFilters` (session-backed when the implementation uses table filters) + +**Validation rules**: +- requested tenant filters must resolve to an entitled tenant or the page should respond as not found for explicit tenant targeting +- state that needs to survive Livewire interactions must remain hydrated public or query/session-backed state +- if implementation adds a secondary status filter, it must operate on customer-safe derived labels only, not raw internal lifecycle states + +## State Transition Summary + +This slice introduces no new persisted lifecycle or status family. Only derived page-state transitions are expected: + +- default workspace view -> tenant-prefiltered view +- tenant-prefiltered view -> cleared workspace view +- published review available -> inspect or download action available subject to capability checks +- no published review available -> truthful absence message only + +No queue, publish, generate, regenerate, remediate, or archive transition belongs to this page. \ No newline at end of file diff --git a/specs/249-customer-review-workspace/plan.md b/specs/249-customer-review-workspace/plan.md new file mode 100644 index 00000000..551b13fe --- /dev/null +++ b/specs/249-customer-review-workspace/plan.md @@ -0,0 +1,310 @@ +# Implementation Plan: Customer Review Workspace v1 + +**Branch**: `249-customer-review-workspace` | **Date**: 2026-04-27 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from [spec.md](spec.md) + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Introduce one canonical customer-safe review workspace inside the existing `/admin` plane by adding a native Filament v5 read-only page that derives its content from existing tenant review, review-pack, evidence, findings, redaction, and audit truth. The page should answer the first customer question quickly, then reuse the existing tenant-scoped review, review-pack, and evidence detail routes for proof instead of creating a new truth layer. + +This slice is explicitly consumption-only. It does not create or publish reviews, generate or regenerate review packs, remediate findings, widen the identity model, or add persistence. Livewire remains v4 under Filament v5, panel-provider registration stays in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new globally searchable resource is introduced, and no new asset bundle is expected for v1. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services +**Storage**: PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned +**Testing**: Pest v4 feature coverage plus one browser smoke slice, with optional narrow unit coverage only if a row-composition helper emerges during implementation +**Validation Lanes**: confidence, browser +**Target Platform**: Laravel monolith in `apps/platform` running via Sail, with existing `/admin` and tenant-scoped `/admin/t/{tenant}` surfaces +**Project Type**: Web application (Laravel monolith with Filament panels) +**Performance Goals**: page render remains DB-only and workspace-scoped; no Graph calls, no queue starts, and no remote work on render; latest review lookup should stay within one eager-loaded workspace read path +**Constraints**: preserve deny-as-not-found workspace and tenant isolation; keep the first slice in the existing admin plane; keep raw diagnostics and debug semantics out of the default path; avoid new persistence, new customer role families, and new presenter/taxonomy layers +**Scale/Scope**: 1 new admin page, 1 derived workspace summary per entitled tenant, reuse of 5 existing read surfaces and their services, 0 new runtime entities, and 1 explicit browser smoke slice + +## Likely Affected Repo Surfaces + +- [../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php](../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php) for the existing workspace review register pattern, filter behavior, and action-surface expectations. +- [../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php](../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php) for canonical workspace-page state persistence, tenant-prefilter handling, and clickable-row read-only reporting patterns. +- [../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php) as the preferred entitlement and workspace query seam to extend or reuse before adding any new helper. +- [../../apps/platform/app/Filament/Resources/TenantReviewResource.php](../../apps/platform/app/Filament/Resources/TenantReviewResource.php), [../../apps/platform/app/Filament/Resources/ReviewPackResource.php](../../apps/platform/app/Filament/Resources/ReviewPackResource.php), and [../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php](../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php) for existing tenant-scoped proof routes, action-surface rules, and capability gates. +- [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php), [../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php](../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php), and [../../apps/platform/routes/web.php](../../apps/platform/routes/web.php) for signed download generation, current pack availability semantics, and the real download route. +- [../../apps/platform/app/Services/TenantReviews/TenantReviewService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewService.php) plus the existing `TenantReview` summary/section payloads for published review truth, findings summaries, and accepted-risk source data. +- [../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php](../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php) and [../../apps/platform/app/Support/RedactionIntegrity.php](../../apps/platform/app/Support/RedactionIntegrity.php) for customer-safe outcome and redaction wording. +- [../../apps/platform/app/Services/Auth/RoleCapabilityMap.php](../../apps/platform/app/Services/Auth/RoleCapabilityMap.php), [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php), and existing tenant review / evidence policies for capability-first RBAC. +- [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) and [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) for audit reuse. +- Likely new implementation files if code work later proceeds: `App\Filament\Pages\Reviews\CustomerReviewWorkspace`, a matching Blade view under `resources/views/filament/pages/reviews/`, and focused tests under `tests/Feature/Reviews/` and `tests/Browser/Reviews/`. + +## UI / Filament & Livewire Fit + +- Implement as a native Filament v5 `Page` using the same `HasTable` / `InteractsWithTable` style already used by the workspace review and evidence overview pages. Do not introduce a new Resource, portal shell, custom SPA, or second panel. +- Keep the page in the existing admin-plane reporting family with one primary inspect affordance and, at most, one inline safe download shortcut. The workspace page itself should not expose bulk actions, More-menu sprawl, or lifecycle controls. +- Livewire v4 hydration must preserve tenant prefilter and launch-context state through public, query-backed, or session-backed state. Do not rely on private page properties for any state that must survive a Livewire interaction. +- Tenant detail links should continue using the existing tenant-scoped route helpers from the resource layer so the workspace page stays a navigation surface, not a duplicate detail renderer. +- The new surface is a Page, not a globally searchable Resource. Existing tenant review, review-pack, and evidence resources already have global search disabled, and that remains unchanged for this slice. + +## RBAC / Policy Fit + +- Workspace membership remains the first gate. The preferred access check is the existing workspace-entitlement path already used by `TenantReviewRegisterService::canAccessWorkspace(...)` and the current workspace context. +- The safe v1 audience anchor remains the existing readonly-capable tenant role in [../../apps/platform/app/Services/Auth/RoleCapabilityMap.php](../../apps/platform/app/Services/Auth/RoleCapabilityMap.php). No new customer role family or external customer identity plane is planned. +- Page visibility and row composition should derive entitled tenants from the canonical capability registry: `TENANT_VIEW` plus `TENANT_REVIEW_VIEW` for page entry, with `REVIEW_PACK_VIEW`, `EVIDENCE_VIEW`, `TENANT_FINDINGS_VIEW`, `FINDING_EXCEPTION_VIEW`, and `AUDIT_VIEW` gating optional secondary proof. +- Non-members or explicit out-of-scope tenant targets must resolve as not found. Once workspace and tenant membership are established, missing secondary capabilities remain normal authorization failures for execution paths and should not leak hidden content through the UI. +- Policy and gate checks stay capability-first. No role-string checks or customer-only bypasses should appear in the implementation. + +## Audit / Logging Fit + +- Reuse the existing audit infrastructure through `WorkspaceAuditLogger` and `AuditActionId`. The feature should not create a separate audit store, mirror page-view ledger, or custom analytics table. +- Existing review export generation already logs `tenant_review.exported`, and review-pack download already has a real signed route. The plan assumes explicit customer-facing artifact open/download events can remain on the current audit pipeline. +- If the workspace page needs a distinct access event beyond what current review-pack or review actions already capture, add a stable `AuditActionId` case and log it through the shared audit path rather than page-local ad hoc logging. +- Default page render should not emit noisy audit events. The auditable boundary is explicit artifact access or download, not passive page paint. + +## Data & Query Fit + +- The preferred row source is a derived workspace read over entitled tenants and their latest published `TenantReview`, with eager-loaded `tenant`, `evidenceSnapshot`, and `currentExportReviewPack` relations. +- Accepted-risk and findings summaries should come from existing review summary / section payloads, not from a new customer-specific aggregation model. `TenantReviewSectionFactory` already shapes accepted-risk and finding outcome data into the review artifact. +- Absence handling must remain derived view logic. The page may surface `No published review available yet` or pack-unavailable messaging, but that language must not become a new persisted lifecycle or publication taxonomy. +- No new table, cache, or materialized view is planned. If the existing register service cannot express the exact latest-published-per-tenant query safely, extend that service or add a bounded page-local read helper rather than introducing a new projector or presenter family. +- Pack availability should remain tied to the current review/export relationship and existing signed download semantics. The page must not trigger generation or regeneration. + +## UI / Surface Guardrail Plan + +> **Fill for operator-facing or guardrail-relevant workflow changes. Docs-only or template-only work may use concise `N/A`. Copy the spec classification forward; do not rename or expand it here.** + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament +- **Shared-family relevance**: reporting, evidence/report viewers, navigation entry points, review/download actions, disclosure hierarchy +- **State layers in scope**: page, URL-query +- **Audience modes in scope**: customer/read-only, operator-MSP +- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third +- **Raw/support gating plan**: capability-gated and hidden by default through reused detail routes only +- **One-primary-action / duplicate-truth control**: the dominant next action remains `Open latest review`; download is the only inline safe shortcut, and deeper proof stays on existing detail surfaces instead of being repeated on the workspace page +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory now, future hard-stop candidate if implementation introduces a second customer-review truth path +- **Special surface test profiles**: standard-native-filament, shared-detail-family +- **Required tests or manual smoke**: functional-core, bounded-browser-smoke +- **Exception path and spread control**: none expected; any need for a local presenter, custom disclosure taxonomy, or new detail shell should be treated as exception-required drift +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage + +## 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 +- **Systems touched**: `ReviewRegister`, `EvidenceOverview`, `TenantReviewRegisterService`, `TenantReviewResource`, `ReviewPackResource`, `EvidenceSnapshotResource`, `ReviewPackService`, `TenantReviewService`, `ArtifactTruthPresenter`, `SurfaceCompressionContext`, `ActionSurfaceDeclaration`, `RedactionIntegrity`, `WorkspaceAuditLogger`, `AuditActionId`, and the existing capability / policy seams +- **Shared abstractions reused**: `TenantReviewRegisterService`, `ArtifactTruthPresenter`, `SurfaceCompressionContext`, Filament action-surface declarations, existing tenant-scoped resource URL helpers, `ReviewPackService`, `RedactionIntegrity`, and the existing audit pipeline +- **New abstraction introduced? why?**: none by default. If implementation discovers that the current register service cannot safely express latest-published-per-tenant rows, the smallest acceptable addition is a bounded read helper or service extension for this page only +- **Why the existing abstraction was sufficient or insufficient**: current review, evidence, and pack abstractions already hold the truth and disclosure language; they are insufficient only because there is no calm customer-safe workspace entry point today +- **Bounded deviation / spread control**: none planned. The new page must compose existing truth, not rename it or mirror it into a customer-specific presenter framework + +## OperationRun UX Impact + +> **Fill when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`. Docs-only or template-only work may use concise `N/A`.** + +- **Touches OperationRun start/completion/link UX?**: no +- **Central contract reused**: `N/A` +- **Delegated UX behaviors**: `N/A` +- **Surface-owned behavior kept local**: read-only inspection and signed artifact download only; any existing `OperationRun` links stay on reused detail surfaces and are not promoted into the default customer path +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception path**: none + +## Provider Boundary & Portability Fit + +> **Fill when the feature touches shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth. Docs-only or template-only work may use concise `N/A`.** + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: `N/A` +- **Platform-core seams**: existing workspace, tenant, review, evidence, findings, and audit vocabulary only +- **Neutral platform terms / contracts preserved**: `workspace`, `tenant`, `review`, `evidence`, `review pack`, `accepted risk`, and existing artifact-truth wording +- **Retained provider-specific semantics and why**: none; the feature consumes provider-shaped artifacts only through already-normalized platform surfaces +- **Bounded extraction or follow-up path**: `N/A` + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first / snapshot truth: PASS. The slice consumes existing `TenantReview`, `ReviewPack`, and `EvidenceSnapshot` artifacts as read-only truth and does not redefine source-of-truth boundaries. +- Read/write separation: PASS. The workspace page is read-only and adds no create, publish, regenerate, expire, triage, or remediation action. Any destructive-like action that already exists on reused detail pages remains outside the default path and must continue using confirmation. +- Graph contract path: PASS. No new Graph calls or provider contract work are part of this slice. +- Deterministic capabilities: PASS. The plan reuses the canonical capability registry in [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php) and the existing role map. +- Workspace isolation + tenant isolation: PASS. Workspace membership remains a 404 boundary, tenant entitlement remains a 404 boundary, and explicit out-of-scope tenant filters must not leak existence. +- RBAC-UX plane separation: PASS. Everything stays in the existing `/admin` plane and tenant-scoped detail routes; no `/system` surface or cross-plane flow is added. +- Destructive confirmation standard: PASS. No destructive action is planned on the workspace page. If implementation later discovers any destructive affordance on a reused surface must be exposed, it must remain confirmation-protected and out of the default customer-safe path. +- Global search safety: PASS. The new slice is a Page, not a Resource. Existing tenant review, review-pack, and evidence resources are already not globally searchable, and no new searchable resource is introduced. +- OperationRun and Ops-UX: PASS by non-use. The workspace page starts no run, emits no run UX, and performs no queue orchestration. +- Data minimization: PASS. Default-visible content stays decision-first; raw JSON, unrestricted audit metadata, fingerprints, debug semantics, and raw provider payloads stay hidden. +- Test governance (TEST-GOV-001): PASS. Planned proof stays in focused feature tests plus one bounded browser smoke slice, with optional unit coverage only if a local read helper appears. +- Proportionality / no premature abstraction: PASS. No new persistence, presenter family, enum family, or identity plane is planned; the narrowest shape is one page over existing truth seams. +- Persisted truth (PERSIST-001): PASS. No new table, cache, or stored customer-review projection is planned. +- Behavioral state (STATE-001): PASS. Any unavailable or no-published-review wording remains derived UI state, not a new persisted lifecycle. +- UI semantics / shared pattern first / Filament-native UI: PASS. The plan reuses Filament pages/resources, existing badge and artifact-truth presenters, existing download service, and the current disclosure language rather than inventing a new UI framework. +- Provider boundary (PROV-001): PASS. The slice stays within already-normalized review and evidence artifacts and does not deepen provider coupling. +- Filament / Laravel planning contract: PASS. Filament v5 remains on Livewire v4, provider registration stays in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new panel registration is needed, and no new panel-only or shared asset registration is expected. +- Asset strategy: PASS. Default assumption is no new assets. If implementation later registers any Filament asset anyway, deployment continues to require `cd apps/platform && php artisan filament:assets`. + +**Gate evaluation**: PASS. + +- The slice stays inside the existing admin plane and current workspace/tenant membership model. +- The page remains a customer-safe consumption surface, not a new review-generation or remediation workflow. +- Existing review, evidence, pack, redaction, and audit seams are sufficient for v1 if the implementation resists adding a second presenter or persistence layer. + +**Post-design re-check**: PASS (design artifacts: [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), [contracts/customer-review-workspace.openapi.yaml](contracts/customer-review-workspace.openapi.yaml)). + +## Test Governance Check + +> **Fill for any runtime-changing or test-affecting feature. Docs-only or template-only work may state concise `N/A` or `none`.** + +- **Test purpose / classification by changed surface**: Feature for workspace-page behavior, authorization, and pack-access semantics; Browser for the calm customer-safe disclosure smoke path; optional Unit only if a bounded row-composition helper is extracted +- **Affected validation lanes**: confidence, browser +- **Why this lane mix is the narrowest sufficient proof**: feature coverage is the cheapest way to prove deny-as-not-found behavior, capability-gated secondary actions, empty states, and signed download wiring on a native Filament page; one browser smoke is justified because the product value of this slice is the disclosure experience itself +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` +- **Fixture / helper / factory / seed / context cost risks**: moderate but contained; reuse existing workspace membership, tenant membership, published review, ready pack, evidence snapshot, findings, and finding-exception fixtures rather than introducing heavy new helpers +- **Expensive defaults or shared helper growth introduced?**: no; any helper added for row composition or launch-context state should stay explicit and page-local +- **Heavy-family additions, promotions, or visibility changes**: exactly one browser smoke slice only; no broader browser family or heavy-governance lane expansion should be needed +- **Surface-class relief / special coverage rule**: standard-native-filament relief for route, auth, filters, and empty states; shared-detail-family checks only where navigation into existing review/pack/evidence surfaces needs proof +- **Closing validation and reviewer handoff**: rerun the four focused commands above, verify the page never shows admin or remediation controls by default, verify out-of-scope tenant targeting stays 404-safe, and verify download remains signed and capability-bound through the existing pack path +- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local browser cost +- **Review-stop questions**: lane fit, hidden fixture cost, accidental browser family growth, duplicated detail rendering, audit proof adequacy +- **Escalation path**: `document-in-feature` for contained audit-test placement drift; `reject-or-split` if implementation grows into write workflows, new persistence, or a larger browser suite +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage +- **Why no dedicated follow-up spec is needed**: routine test and disclosure upkeep stays inside this feature unless implementation proves a structural need for a broader customer-access program + +## Rollout & Risk Controls + +- Keep the v1 audience anchored to the current readonly-capable tenant role plus existing review/evidence capabilities. No navigation or deep link should become visible without those gates. +- Treat the page as a new read-only entry point only. Do not move generation, publish, regenerate, refresh, expire, triage, or remediation controls onto it during implementation. +- Prefer extending the existing register and detail seams over introducing any persisted projection, local presenter family, or customer-only vocabulary. +- Keep signed pack download on the existing route and controller path. Do not replace it with a new download endpoint just for the workspace page. +- Validate the page with one browser smoke before considering any broader navigation prominence changes. The rollout risk is disclosure drift, not infrastructure change. +- No migration, queue worker change, or asset build sequence change is expected for this slice. + +## Project Structure + +### Documentation (this feature) + +```text +specs/249-customer-review-workspace/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── customer-review-workspace.openapi.yaml +└── tasks.md # Created later by /speckit.tasks, not by this plan step +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/Reviews/ +│ │ │ ├── ReviewRegister.php +│ │ │ └── CustomerReviewWorkspace.php # likely new page if implementation proceeds +│ │ ├── Pages/Monitoring/EvidenceOverview.php +│ │ └── Resources/ +│ │ ├── TenantReviewResource.php +│ │ ├── ReviewPackResource.php +│ │ └── EvidenceSnapshotResource.php +│ ├── Http/Controllers/ReviewPackDownloadController.php +│ ├── Models/TenantReview.php +│ ├── Services/ +│ │ ├── Audit/WorkspaceAuditLogger.php +│ │ ├── ReviewPackService.php +│ │ └── TenantReviews/ +│ │ ├── TenantReviewRegisterService.php +│ │ └── TenantReviewService.php +│ ├── Support/ +│ │ ├── Audit/AuditActionId.php +│ │ ├── Auth/Capabilities.php +│ │ ├── RedactionIntegrity.php +│ │ └── Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php +│ └── Policies/ +│ ├── TenantReviewPolicy.php +│ └── EvidenceSnapshotPolicy.php +├── bootstrap/providers.php +├── resources/views/filament/pages/reviews/ # likely new page view if implementation proceeds +├── routes/web.php +└── tests/ + ├── Browser/Reviews/ + ├── Feature/Reviews/ + └── Feature/ReviewPack/ +``` + +**Structure Decision**: Laravel monolith. Implementation should stay entirely inside `apps/platform`, add at most one new read-only page and matching Blade view, and reuse the existing review, pack, evidence, RBAC, and audit seams rather than creating a separate customer-facing subsystem. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| None expected | The intended implementation is one native page plus derived queries over existing truth | A separate portal, persistence layer, or customer presenter framework would import unnecessary scope and ownership cost | + +## Proportionality Review + +- **Current operator problem**: review artifacts already exist, but readonly-capable tenant actors still lack one coherent customer-safe workspace surface to consume them. +- **Existing structure is insufficient because**: current review, pack, and evidence surfaces are truthful but fragmented and more operator-oriented than a calm customer-first entry point. +- **Narrowest correct implementation**: add one native Filament page over existing tenant review, review-pack, evidence, findings, redaction, and audit seams, with only derived row composition and no new persistence. +- **Ownership cost created**: one page, one view, one bounded query/composition seam if required, and focused feature/browser tests. +- **Alternative intentionally rejected**: a new customer portal, a new identity plane, and a new persisted customer-review projection were all rejected because existing admin-plane RBAC and review artifacts are sufficient for the first slice. +- **Release truth**: current-release customer-safe consumption slice, not future-release portal preparation. + +## Phase 0 — Research (output: research.md) + +Research resolves the remaining implementation-shaping decisions: + +- place the new page in the existing admin-plane reviews family rather than extending `ReviewRegister` into a dual-persona page or creating a portal shell +- reuse `TenantReviewRegisterService` as the entitlement/query seam before adding any new helper +- keep tenant prefilter and launch context in Livewire-safe public/query/session-backed state +- reuse `ArtifactTruthPresenter`, existing review summary payloads, and `RedactionIntegrity` for disclosure instead of adding a customer presenter layer +- reuse the existing signed review-pack download route and audit pipeline rather than inventing a new consumption endpoint + +**Output**: [research.md](research.md) + +## Phase 1 — Design (outputs: data-model.md, contracts/, quickstart.md) + +Design artifacts capture the narrow implementation shape: + +- no new persistence; reused truth stays in tenant reviews, review packs, evidence snapshots, findings/exceptions, memberships, and audit logs +- one derived workspace entry model documents what the new page must compose without becoming a stored entity +- the conceptual contract documents the page route, tenant-detail handoff, and signed pack-download semantics +- quickstart records the intended implementation order, validation commands, Filament/Livewire assumptions, provider-registration location, and no-new-assets posture + +**Artifacts**: + +- [data-model.md](data-model.md) +- [contracts/customer-review-workspace.openapi.yaml](contracts/customer-review-workspace.openapi.yaml) +- [quickstart.md](quickstart.md) + +## Phase 2 — Planning (for tasks.md) + +Dependency-ordered implementation outline for the later `tasks.md` step: + +1. Add a native `CustomerReviewWorkspace` admin page and Blade view in the reviews family, keeping the action surface read-only and customer-safe. +2. Reuse or minimally extend `TenantReviewRegisterService` to resolve workspace access, entitled tenants, and the latest published review per entitled tenant with the required eager loads. +3. Compose row content from existing review summary / section payloads, `ArtifactTruthPresenter`, current export review-pack relationships, and `RedactionIntegrity` notes, without creating a new presenter or persistence layer. +4. Preserve launch-context tenant prefiltering and Livewire-safe filter state using the same workspace-page state patterns already proven in `ReviewRegister` and `EvidenceOverview`. +5. Wire the dominant inspect action to the existing tenant-scoped review detail route and keep review-pack download on the current signed route; evidence drilldown remains explicit and capability-gated. +6. Reuse the audit pipeline for explicit artifact access or download events only if the current path does not already emit a truthful stable event; do not add a new audit store. +7. Add focused feature coverage for page behavior, authorization, and pack access, then one browser smoke test for calm disclosure and absence of admin controls. Run Pint after implementation. + +## Guardrail / Exception / Smoke Coverage + +- Guardrail result: PASS. Filament remains v5 on Livewire v4, panel provider registration stays unchanged in `apps/platform/bootstrap/providers.php`, the feature adds no new globally searchable Resource, no destructive action on the workspace page, and no new asset bundle. Deployment asset handling stays unchanged: `cd apps/platform && php artisan filament:assets` only matters if future registered assets are added. +- Shared seam outcome: `TenantReviewRegisterService` remained the entitlement and latest-published query seam. No local helper or second customer-review truth layer was introduced. +- Launch-path outcome: direct customer-workspace links landed on tenant review detail, review-pack detail, evidence related context, and the tenant review-pack widget. `ReviewRegister` and `EvidenceOverview` were satisfied through existing row/detail navigation reuse instead of duplicate workspace buttons. +- Read-only detail outcome: the workspace handoff now appends a dedicated customer-workspace context query flag, and `ViewTenantReview` suppresses management actions in that customer-safe flow while preserving the established operator detail route behavior outside that flow. +- Pack-download outcome: the existing signed route was retained, but it now also enforces tenant membership plus `REVIEW_PACK_VIEW` at request time. That touched download plumbing and required `ReviewPackDownloadTest.php` plus `ReviewPackRbacTest.php` updates. +- Audit outcome: the existing audit infrastructure was reused with additive `tenant_review.opened` and `review_pack.downloaded` action IDs logged through `WorkspaceAuditLogger`. No new audit store or parallel logging path was introduced. +- Validation lane result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/ReviewPack/ReviewPackWidgetTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackRbacTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` passed with `83 passed (372 assertions)`. +- Smoke evidence: the executed smoke proof was the bounded Pest browser harness in `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`, which passed with `1 passed (19 assertions)`. No separate manual integrated-browser run was performed. +- Formatting result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` passed. +- Review outcome class: `acceptable-special-case`. +- Workflow outcome: `keep`. +- Exception note: none. The implementation stayed within the planned admin-plane, read-only, shared-primitives-first shape. diff --git a/specs/249-customer-review-workspace/quickstart.md b/specs/249-customer-review-workspace/quickstart.md new file mode 100644 index 00000000..680f6e80 --- /dev/null +++ b/specs/249-customer-review-workspace/quickstart.md @@ -0,0 +1,59 @@ +# Quickstart — Customer Review Workspace v1 + +## Preconditions + +- Docker is running and the Sail stack for `apps/platform` is available. +- The feature remains inside the existing Laravel monolith and admin plane. +- The first slice stays read-oriented: no new customer portal, no new identity plane, no new persistence, and no remediation or generation workflow. + +## Intended Implementation Order + +1. Add the native admin `CustomerReviewWorkspace` page and its Blade view under the existing reviews family. +2. Reuse or minimally extend `TenantReviewRegisterService` to resolve workspace membership, entitled tenants, and latest published reviews per entitled tenant. +3. Compose customer-safe row content from existing `TenantReview` summary / section payloads, `ArtifactTruthPresenter`, `currentExportReviewPack`, and `RedactionIntegrity`. +4. Preserve tenant launch context and remembered filters through Livewire-safe public/query/session-backed state. +5. Wire `Open latest review` to the existing tenant-scoped review detail route and keep review-pack consumption on the existing signed download path. +6. Reuse the existing audit pipeline for any explicit artifact access event that is not already covered by the current review / export flow. +7. Add focused feature coverage and one browser smoke test, then run Pint. + +## Targeted Validation Commands (after implementation) + +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` +- If implementation changes pack-download plumbing directly: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackDownloadTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## Smoke Checklist Reference (after implementation) + +Implementation close-out used the bounded browser smoke in `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` plus the focused feature lane as the executed smoke evidence. The checklist below remains the human reference checklist, but no separate manual integrated-browser run was executed for this implementation close-out. + +1. Sign in to `/admin` as a readonly-capable tenant actor, select a workspace, and open `/admin/reviews/workspace`. +2. Confirm that the page shows only entitled tenants, the latest customer-safe review posture, and no create, publish, regenerate, refresh, expire, triage, or remediation controls. +3. Launch the page from an existing tenant-scoped review or evidence route and confirm the tenant prefilter survives the first page load. +4. Open the latest review for a tenant with a published review and confirm the detail remains read-oriented for the readonly actor. +5. Use the pack action for a tenant with a current pack and confirm the download path stays signed and customer-safe; for a tenant without a current pack, confirm the page shows a calm unavailable state instead of a generation action. +6. Attempt an explicit out-of-scope tenant filter or deep link and confirm the result stays not found without leaking tenant existence. + +## Executed Validation Evidence + +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/ReviewPack/ReviewPackWidgetTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackRbacTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` -> `83 passed (372 assertions)` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` -> `1 passed (19 assertions)` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` -> `pass` + +## Close-out Notes + +- `TenantReviewRegisterService` reuse held; no page-local helper was needed. +- The review-pack download route remained signed, but now also enforces tenant membership and `REVIEW_PACK_VIEW` at request time. +- Explicit artifact access is now audited through additive `tenant_review.opened` and `review_pack.downloaded` action IDs on the existing audit pipeline. +- `ReviewRegister` and `EvidenceOverview` satisfied the launch-path requirement through existing row/detail navigation reuse rather than new duplicate workspace buttons. + +## Notes + +- Filament v5 already runs on Livewire v4 in this repo. +- Panel providers remain registered through [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php); this slice does not add or move providers. +- No new globally searchable Resource is part of v1. Existing review, review-pack, and evidence Resources already keep global search disabled. +- No destructive action belongs on the new workspace page. If implementation accidentally introduces one, it must use `->requiresConfirmation()` and stay outside the customer-safe default path. +- No new registered asset bundle is expected. If implementation later registers a Filament asset anyway, deployment still requires `cd apps/platform && php artisan filament:assets`. +- This remains a customer-safe consumption slice only. Review creation, publication, regeneration, remediation, and operator/debug workflows remain on existing internal surfaces or future specs. \ No newline at end of file diff --git a/specs/249-customer-review-workspace/research.md b/specs/249-customer-review-workspace/research.md new file mode 100644 index 00000000..84897466 --- /dev/null +++ b/specs/249-customer-review-workspace/research.md @@ -0,0 +1,166 @@ +# Research — Customer Review Workspace v1 + +**Date**: 2026-04-27 +**Spec**: [spec.md](spec.md) + +This document resolves the planning decisions that shape the smallest safe implementation slice for Spec 249. + +## Decision 1 — Place the new surface as a native admin reviews page + +**Decision**: Implement the customer-safe workspace as a new native Filament page under the existing admin reviews family, with the planned route shape `/admin/reviews/workspace`. Do not create a new panel, a public/customer portal shell, or a new Resource just to host the view. + +**Rationale**: +- The repo already has native workspace-level read-only pages for reporting and monitoring. +- The existing review, review-pack, and evidence Resources already own tenant-scoped detail and proof routes. +- A dedicated page keeps the first slice calm and customer-safe without overloading an operator-oriented registry. + +**Evidence**: +- [../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php](../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php) already provides the workspace review register pattern. +- [../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php](../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php) already provides a workspace-scoped read-only page pattern with tenant prefilters. +- [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php) already registers the existing panel providers. No new provider registration is needed. + +**Alternatives considered**: +- Extend `ReviewRegister` into a dual-persona page. + - Rejected: it already carries operator-oriented filters and export semantics, which would blur the customer-safe default path. +- Create a new customer portal or new identity plane. + - Rejected: outside the bounded v1 scope and unnecessary because the current admin plane plus readonly-capable roles already exists. + +## Decision 2 — Reuse `TenantReviewRegisterService` as the entitlement and query seam + +**Decision**: Prefer extending or reusing [../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php) for workspace access checks, entitled-tenant discovery, and the base review query before adding any new helper. + +**Rationale**: +- The service already centralizes workspace membership and entitled tenant selection for the current review register. +- Reusing it keeps entitlement logic in one place and avoids new raw tenant-role queries inside the page. +- It is the narrowest existing seam that can be extended toward latest-published-per-tenant behavior. + +**Evidence**: +- `authorizedTenants(...)` already derives tenant scope from the canonical role/capability map. +- `query(...)` already scopes tenant reviews to the current workspace and eager-loads `tenant`, `evidenceSnapshot`, and `currentExportReviewPack`. +- `canAccessWorkspace(...)` already exposes the workspace-membership check needed for deny-as-not-found page gating. + +**Alternatives considered**: +- Build a new persisted workspace summary table. + - Rejected: violates the no-new-persistence rule for a derived read surface. +- Recreate entitlement logic directly inside the page class. + - Rejected: duplicates existing membership and capability behavior. + +## Decision 3 — Derive the default path from the latest published tenant review per entitled tenant + +**Decision**: The default workspace page should derive each tenant summary from the latest published `TenantReview` for that entitled tenant, with eager-loaded `currentExportReviewPack` and `evidenceSnapshot` relationships. + +**Rationale**: +- The spec requires the default path to stay customer-safe and exclude draft, failed, and other internal-only states. +- The current `TenantReview` model already distinguishes published reviews and holds the summary relationships the new page needs. +- This keeps the page read-oriented and avoids a separate customer-review lifecycle. + +**Evidence**: +- [../../apps/platform/app/Models/TenantReview.php](../../apps/platform/app/Models/TenantReview.php) already exposes `published()` and `currentExportReviewPack()`. +- [../../apps/platform/app/Services/TenantReviews/TenantReviewService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewService.php) already stores review summary payloads, evidence basis, and export readiness. +- Existing review composition already emits `finding_outcomes` and accepted-risk related payloads through the review artifact family. + +**Alternatives considered**: +- Surface draft or ready reviews when no published review exists. + - Rejected: leaks internal lifecycle meaning into the customer-safe path. +- Create a second customer-review publication model. + - Rejected: duplicates review truth and imports unnecessary workflow complexity. + +## Decision 4 — Keep page state Livewire-safe and tenant-prefilter aware + +**Decision**: Tenant launch context, requested tenant filters, and any remembered page state must live in public, query-backed, or session-backed state, following the existing workspace-page patterns. Do not keep required filter state in private properties. + +**Rationale**: +- Existing workspace pages already show how canonical admin pages preserve tenant prefilters and survive Livewire follow-up requests. +- This repo has already hit Livewire state-reset issues when tenant context lived in non-hydrated private properties. +- The workspace page needs tenant prefiltering from review, evidence, and related entry points. + +**Evidence**: +- [../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php](../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php) uses canonical admin filter-state sync on mount. +- [../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php](../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php) documents its page-state contract for tenant prefilters and remembered search/filter state. +- Repo memory already records that private page state can reset during Livewire actions on admin canonical pages. + +**Alternatives considered**: +- Keep launch context in a private property only. + - Rejected: too brittle across Livewire requests. +- Use only client-side state. + - Rejected: breaks server-side truth and shareable canonical page behavior. + +## Decision 5 — Reuse artifact-truth and redaction seams for customer-safe disclosure + +**Decision**: Reuse [../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php](../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php), `SurfaceCompressionContext`, and [../../apps/platform/app/Support/RedactionIntegrity.php](../../apps/platform/app/Support/RedactionIntegrity.php) for outcome, freshness, and redaction-safe wording. + +**Rationale**: +- These seams already normalize review, review-pack, and evidence truth into operator-safe summaries. +- Reusing them preserves vocabulary and prevents a second customer-review explanation system. +- `RedactionIntegrity` already owns the repo’s protected-value and support-diagnostics notes. + +**Evidence**: +- `ArtifactTruthPresenter::for(...)` already supports `TenantReview`, `ReviewPack`, and `EvidenceSnapshot`. +- `ReviewRegister`, `EvidenceOverview`, `TenantReviewResource`, and `ReviewPackResource` already depend on these truth envelopes. +- `RedactionIntegrity` already defines reusable disclosure notes for protected values and support diagnostics. + +**Alternatives considered**: +- Introduce a customer-only presenter or status taxonomy. + - Rejected: duplicates shared artifact truth and increases review drift risk. +- Inline page-local disclosure strings only. + - Rejected: likely to diverge from existing review and pack semantics. + +## Decision 6 — Keep review-pack consumption on the existing signed download path + +**Decision**: Pack consumption should stay on the existing signed route and download controller, with the workspace page only generating or surfacing the already-authorized download path through [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php). + +**Rationale**: +- The repo already has a real signed download route and a dedicated download controller. +- Reusing that path keeps the new page consumption-only and avoids inventing a customer-specific download endpoint. +- The page must not trigger pack generation or regeneration. + +**Evidence**: +- [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php) already generates signed download URLs. +- [../../apps/platform/routes/web.php](../../apps/platform/routes/web.php) already exposes `/admin/review-packs/{reviewPack}/download` as the signed download route. +- [../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php](../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php) already enforces pack readiness and expiry constraints. + +**Alternatives considered**: +- Add a new workspace-page-specific download endpoint. + - Rejected: duplicates current signed download behavior. +- Offer generate/regenerate from the workspace page. + - Rejected: out of scope and not customer-safe for v1. + +## Decision 7 — Reuse the current audit pipeline and add new action IDs only if needed + +**Decision**: Reuse `WorkspaceAuditLogger` and `AuditActionId` for any explicit artifact access or download events surfaced by the new page, and only add new stable action IDs if the existing review/export path does not already provide a truthful event. + +**Rationale**: +- The repo already has a canonical workspace-scoped audit path. +- This slice needs auditability for explicit artifact consumption, not a new access-analytics subsystem. +- Stable action IDs are preferable to page-local logging if an additional event is truly required. + +**Evidence**: +- [../../apps/platform/app/Services/TenantReviews/TenantReviewService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewService.php) already logs review creation/refresh through `WorkspaceAuditLogger`. +- [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php) already logs review-pack export activity. +- [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) is the stable audit action registry. + +**Alternatives considered**: +- Add a new customer-review audit table. + - Rejected: violates the no-new-persistence rule. +- Emit page-render audits for every visit. + - Rejected: too noisy and not aligned with the explicit-artifact-access requirement. + +## Decision 8 — Keep the slice Filament-native, asset-light, and non-searchable + +**Decision**: Keep the slice on the existing Filament v5 / Livewire v4 stack, do not add a new Resource or global-search entry, and plan for no new asset bundle unless implementation proves otherwise. + +**Rationale**: +- The feature is a new page over existing truth, not a new object family. +- Existing review, pack, and evidence Resources already disable global search because they are tenant-scoped. +- A native page avoids a second shell and keeps the deploy story unchanged. + +**Evidence**: +- `TenantReviewResource`, `ReviewPackResource`, and `EvidenceSnapshotResource` already set global search off. +- Panel providers are already registered in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php). +- The repo’s Filament guidance already expects provider registration to remain in `bootstrap/providers.php` and assets to stay minimal unless explicitly registered. + +**Alternatives considered**: +- Add a new searchable Resource just for the workspace page. + - Rejected: the surface is a page-level dashboard, not a new record type. +- Add a custom asset bundle or custom portal shell up front. + - Rejected: unnecessary for the first read-only slice. \ No newline at end of file diff --git a/specs/249-customer-review-workspace/spec.md b/specs/249-customer-review-workspace/spec.md new file mode 100644 index 00000000..39d66e3d --- /dev/null +++ b/specs/249-customer-review-workspace/spec.md @@ -0,0 +1,299 @@ +# Feature Specification: Customer Review Workspace v1 + +**Feature Branch**: `249-customer-review-workspace` +**Created**: 2026-04-27 +**Status**: Draft +**Input**: User description: "Prepare the Spec Kit feature for Customer Review Workspace v1 as the smallest customer-safe read-only review consumption slice in the existing admin plane, reusing current review, evidence, review-pack, RBAC, redaction, and audit truth without inventing a new customer portal or remediation flow." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot already has strong tenant review, evidence snapshot, and review-pack foundations, but customers and readonly-capable tenant actors still lack one calm, trustworthy workspace surface to consume the latest review state without being dropped into operator-heavy reporting detail. +- **Today's failure**: The product can generate review artifacts, but it cannot yet present them as a clearly customer-safe, read-only review experience. That leaves a sellable release gap and risks pushing readonly actors toward internal surfaces with too much operator context or unclear next steps. +- **User-visible improvement**: An authorized readonly-capable actor can open one workspace review surface, see the latest customer-safe review state per entitled tenant, understand key findings and accepted risks, and open or download existing review artifacts without seeing admin or remediation controls. +- **Smallest enterprise-capable version**: One canonical read-only workspace review page in the current `/admin` plane, defaulting to the latest published customer-safe review per entitled tenant, with calm outcome summaries, accepted-risk visibility, existing review-pack consumption, redaction-safe disclosure, and explicit absence of admin/remediation actions. +- **Explicit non-goals**: No new customer portal, no new identity plane, no new persistence model, no review authoring or publishing workflow, no remediation or exception editing, no review-pack generation/regeneration flow, no support desk workflow, no broad cross-tenant decision inbox, and no raw JSON or platform-debug surface. +- **Permanent complexity imported**: One new canonical read-only page, one bounded derived workspace projection over existing review/evidence/review-pack truth, focused authorization and audit coverage, and one explicit browser smoke slice for customer-safe disclosure. +- **Why now**: The implementation ledger marks this as a P0 release blocker. Existing review strength is real, but customer-safe review consumption is still the clearest missing sellable surface in the current queue. +- **Why not local**: Reusing isolated links into `TenantReviewResource`, `ReviewPackResource`, and `EvidenceSnapshotResource` without a canonical workspace entry point would preserve the current fragmentation and would not create a truthful customer-safe default path. +- **Approval class**: Core Enterprise +- **Red flags triggered**: Multi-surface reuse and customer-facing wording. Defense: the slice stays inside the existing admin plane, imports no new persistence or identity system, reuses current artifact truth and RBAC seams, and explicitly forbids write paths. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view +- **Primary Routes**: + - new canonical admin route for a read-only customer review workspace under `/admin/reviews/workspace` + - existing `/admin/reviews` workspace review register on `App\Filament\Pages\Reviews\ReviewRegister` as supporting context, not the primary customer-safe path + - existing tenant-scoped review detail on `App\Filament\Resources\TenantReviewResource` + - existing tenant-scoped review-pack detail/download on `App\Filament\Resources\ReviewPackResource` + - existing tenant-scoped evidence detail on `App\Filament\Resources\EvidenceSnapshotResource` +- **Data Ownership**: All consumed truth remains tenant-owned and derived from existing `TenantReview`, `ReviewPack`, `EvidenceSnapshot`, finding/exception, and audit records bound to the current workspace and tenant. No new workspace-owned customer-review table, cache, mirror entity, or publication store is introduced. +- **RBAC**: + - workspace membership remains the first isolation boundary + - page entry requires established workspace scope plus at least one entitled tenant where the actor has `Capabilities::TENANT_VIEW` and `Capabilities::TENANT_REVIEW_VIEW` + - tenant rows and deep links only render for tenants the actor can access in the current workspace + - review-pack download remains gated by `Capabilities::REVIEW_PACK_VIEW` + - evidence drilldown remains gated by `Capabilities::EVIDENCE_VIEW` + - findings and accepted-risk sections reuse `Capabilities::TENANT_FINDINGS_VIEW` and `Capabilities::FINDING_EXCEPTION_VIEW` + - audit-related secondary disclosure, if present, remains gated by `Capabilities::AUDIT_VIEW` + - no new role family or customer identity plane is introduced; existing readonly-capable roles in `App\Services\Auth\RoleCapabilityMap` remain authoritative for v1 + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: When launched from a tenant-scoped review, review-pack, evidence, or tenant dashboard surface, the workspace page prefilters to that tenant and highlights its latest customer-safe review first. Without a launch context, it shows all entitled tenants in the current workspace. +- **Explicit entitlement checks preventing cross-tenant leakage**: Workspace membership is checked before page render. Tenant-scoped rows, summaries, and deep links are resolved only for tenants where the actor is both a workspace member and tenant-entitled. Explicit tenant filters or record opens that reference an inaccessible tenant resolve as not found rather than showing an empty hint. + +## 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 +- **Interaction class(es)**: evidence/report viewers, status messaging, navigation entry points, review/download actions, and artifact-truth presentation +- **Systems touched**: `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Pages\Monitoring\EvidenceOverview`, `App\Filament\Resources\TenantReviewResource`, `App\Filament\Resources\ReviewPackResource`, `App\Filament\Resources\EvidenceSnapshotResource`, `App\Services\ReviewPackService`, `App\Services\TenantReviews\TenantReviewService`, `App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter`, `App\Support\RedactionIntegrity`, `App\Support\OperationRunLinks`, existing audit infrastructure, and tenant/workspace authorization seams +- **Existing pattern(s) to extend**: current read-only registry/detail reporting surfaces, existing governance artifact truth envelopes, existing review-pack download semantics, existing redaction notes, and existing workspace/tenant-scoped navigation patterns +- **Shared contract / presenter / builder / renderer to reuse**: `ArtifactTruthPresenter`, `SurfaceCompressionContext`, `ActionSurfaceDeclaration`, `ReviewPackService`, `RedactionIntegrity`, and existing tenant-scoped resource view surfaces +- **Why the existing shared path is sufficient or insufficient**: Existing review/evidence/review-pack surfaces already provide the underlying truth, disclosure semantics, and safe detail rendering. They are insufficient only because they do not offer one calm workspace entry point oriented around customer-safe consumption. The feature should add that entry point, not a parallel truth layer. +- **Allowed deviation and why**: none. The new page must reuse current truth, badge, redaction, and download language instead of inventing a second customer-review vocabulary. +- **Consistency impact**: Outcome, freshness, accepted-risk, pack-availability, and redaction notes must keep the same meaning across the new workspace page and the reused review, evidence, and review-pack detail surfaces. +- **Review focus**: Reviewers must block any new page-local status taxonomy, raw-payload viewer, or customer-specific mirror presenter that duplicates the existing review and artifact truth contracts. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: no +- **Shared OperationRun UX contract/layer reused**: `N/A` +- **Delegated start/completion UX behaviors**: `N/A` +- **Local surface-owned behavior that remains**: The workspace page is read-only. Existing `OperationRun` links stay on reused detail surfaces and are not promoted into the default-visible customer path. +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +N/A - no shared provider/platform boundary is widened. The feature consumes existing review and evidence artifacts without introducing new provider-shaped contracts or customer-identity semantics. + +## 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 | +|---|---|---|---|---|---|---| +| Customer review workspace page | yes | Native Filament page reusing existing review/detail resources | reporting, evidence viewers, download actions, disclosure hierarchy | page state, tenant prefilter state, disclosure state | no | Adds one canonical customer-safe workspace path without creating a separate portal shell | + +## 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 | +|---|---|---|---|---|---|---|---| +| Customer review workspace page | Primary Decision Surface | A readonly-capable tenant actor decides whether the latest review is consumable as-is or needs a follow-up conversation with the workspace operator team | latest customer-safe review outcome, key finding counts, accepted-risk summary, published date, and pack availability | latest review detail, review-pack detail/download, and evidence detail only when explicitly opened and capability-allowed | Primary because it becomes the first truthful customer-safe entry point instead of forcing users to reconstruct the answer from internal reporting resources | Keeps review consumption inside one calm workspace path and uses existing detail routes only when the user asks for proof | Replaces cross-surface searching with one page that summarizes what matters first and delays diagnostics until requested | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Customer review workspace page | customer-read-only, operator-MSP | latest published review state, executive outcome, key findings, accepted risks, published or generated time, and review-pack availability | deeper evidence freshness, full section detail, and secondary related links only after explicit open | raw JSON, unrestricted audit metadata, provider payloads, and platform-only debug semantics remain hidden and are never part of the default page | `Open latest review` | raw/support detail is excluded from the page; evidence and audit drilldown remain capability-gated on reused detail routes | the workspace page states one summary truth per tenant and relies on existing review/pack/evidence detail pages for proof instead of repeating the same explanation in parallel blocks | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Customer review workspace page | List / Table / Read-only workspace report | Read-only registry report | Open the latest review for one tenant or download the latest available review pack | full-row navigation to the latest customer-safe tenant review | required | one safe inline download shortcut when a pack is already available; any deeper proof remains inside the opened detail view | none | `/admin/reviews/workspace` | `/admin/t/{tenant}/reviews/{record}` with secondary reuse of tenant-scoped review-pack and evidence detail routes | workspace context, tenant filter, and latest published-review status | Customer review | whether a tenant has a current customer-safe review, what it says at a high level, and whether a pack is available | 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Customer review workspace page | Readonly tenant actor inside the existing admin plane | Consume the latest customer-safe review and decide whether a follow-up conversation is needed | Workspace read-only review overview | What is the latest reviewed state for my entitled tenant, what risks are already accepted, and what can I safely open or download? | tenant identity, latest published review state, outcome summary, key findings summary, accepted-risk summary, latest review time, and review-pack availability | secondary proof routes, evidence freshness detail, and audit-aware artifact provenance only after explicit drilldown | review lifecycle, governance outcome, evidence freshness, pack availability | none | Open latest review, Download review pack | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: no. V1 should reuse existing review, evidence, redaction, and artifact-truth seams directly. +- **New enum/state/reason family?**: no +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Review artifacts already exist, but there is still no product-honest customer-safe way to consume them as a coherent workspace review experience. +- **Existing structure is insufficient because**: Existing review register and tenant-scoped resource views are good proof surfaces, but they are not a calm customer-default path and they spread the answer across several internal pages. +- **Narrowest correct implementation**: Add one read-only workspace page over existing tenant review, review-pack, evidence, redaction, and RBAC truth, and defer any customer-specific identity, publishing workflow, or portal shell. +- **Ownership cost**: One page, one bounded workspace query/projection, focused authorization tests, and a small browser smoke slice. +- **Alternative intentionally rejected**: A separate customer portal or customer-specific persistence model was rejected because the repo already has the required review artifacts and readonly-capable roles in the current admin plane. +- **Release truth**: current-release blocker, not future-release preparation + +### 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, Browser +- **Validation lane(s)**: confidence, browser +- **Why this classification and these lanes are sufficient**: Focused feature tests prove workspace and tenant isolation, capability gating, default-visible disclosure, deep-link rules, and no-write behavior. One explicit browser smoke test proves the calm read-only surface, the absence of admin actions, and the expected open/download flow under realistic UI conditions. +- **New or expanded test families**: one bounded `Reviews/CustomerReviewWorkspace` feature family and one explicit browser smoke test for the same surface +- **Fixture / helper cost impact**: moderate but contained; reuse existing workspace membership, tenant membership, tenant review, review pack, evidence snapshot, finding, finding exception, and audit fixtures instead of adding new heavy provider or queue defaults +- **Heavy-family visibility / justification**: exactly one browser smoke is justified because the core value of this slice is a customer-safe disclosure experience; no broader browser or heavy-governance family is introduced +- **Special surface test profile**: standard-native-filament, shared-detail-family +- **Standard-native relief or required special coverage**: standard Filament feature coverage is sufficient for routing, authorization, empty states, and deep-link rules; a single browser smoke should verify that the default-visible page stays calm and read-only +- **Reviewer handoff**: Reviewers must confirm that readonly actors can use the surface, unauthorized tenant filters or deep links do not leak tenant presence, raw diagnostics never appear by default, and no create, publish, regenerate, refresh, expire, triage, or remediation action becomes visible on the customer workspace page. +- **Budget / baseline / trend impact**: low feature-local increase only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` + +## Scope Boundaries + +### In Scope + +- one canonical workspace-level read-only customer review surface in the existing admin plane +- latest published customer-safe review state per entitled tenant +- key findings and accepted-risk summaries derived from existing review and finding-exception truth +- opening existing tenant review detail pages from the workspace surface +- opening or downloading existing review-pack artifacts when already available and permitted +- optional drilldown into existing evidence detail only through explicit, capability-gated navigation +- redaction-safe disclosure using existing redaction semantics and notes +- auditability for explicit artifact access and download actions using the current audit infrastructure + +### Non-Goals + +- any new customer portal shell, customer account model, or external identity plane +- authoring, publishing, archiving, regenerating, refreshing, expiring, or deleting review artifacts +- exception editing, risk acceptance changes, or findings remediation flows +- raw JSON, provider payloads, unrestricted audit metadata, support diagnostics, or platform-debug semantics in the default path +- new review persistence, new publication state families, or new workspace-owned review entities +- support desk flow, billing, contracts, or broader customer lifecycle workflows +- cross-tenant decision inboxes, promotion workflows, or broad MSP workboards + +## Assumptions + +- The customer-safe default path should use the latest published review for each entitled tenant. Draft, failed, or otherwise internal-only review states stay off the default workspace page. +- Existing readonly-capable tenant roles are sufficient for v1 and do not require a new customer-only role family. +- Accepted-risk disclosure can be derived from existing finding and finding-exception truth without creating a parallel customer-review reason model. +- Existing redaction notes and review-pack download controls are sufficient for v1 customer-safe disclosure. + +## Risks + +- Some tenants may have strong internal review artifacts but no published customer-safe review yet, which can make the new surface appear empty unless absence states are explained clearly. +- Existing review detail pages may still contain operator-oriented sections that need tighter entry rules or more careful disclosure when reached from the new workspace path. +- Partial capability combinations could produce uneven disclosure if the implementation does not clearly separate page-level access from optional deep-link sections. +- A later implementation could try to fold review-pack generation or broader customer portal scope into this slice; that must be rejected as out-of-scope growth. + +## Follow-up Candidates + +- customer-facing portal or external identity work only if the current admin-plane read-only model becomes insufficient +- support diagnostic pack linkage from customer review artifacts once the support packaging flow needs direct customer-facing entry +- explicit review publication workflow maturity if published versus ready review semantics need a broader operator workflow +- broader customer lifecycle and commercial packaging once review consumption no longer fits inside the existing admin plane + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Open the latest customer-safe review (Priority: P1) + +As a readonly-capable tenant actor, I want one workspace page that shows the latest customer-safe review state for my entitled tenant so I can understand the current posture without navigating several internal reporting screens. + +**Why this priority**: This is the core product gap. If the user still needs to reconstruct the latest review state manually, the slice fails its purpose. + +**Independent Test**: Sign in as a readonly-capable tenant actor with one or more entitled tenants, open the customer review workspace, and verify that each visible tenant row shows only the latest published customer-safe review summary. + +**Acceptance Scenarios**: + +1. **Given** the actor is entitled to one or more tenants with published reviews, **When** they open the workspace review page, **Then** they see one latest customer-safe review entry per entitled tenant and no draft-only review rows. +2. **Given** the actor launches the page from a tenant-scoped review or evidence route, **When** the workspace page opens, **Then** that tenant is prefiltered and its latest published review is highlighted first. +3. **Given** the actor has no entitled tenants with published reviews, **When** they open the page, **Then** they see a truthful absence state that does not reveal hidden drafts or inaccessible tenants. + +--- + +### User Story 2 - Understand findings and accepted risks without admin controls (Priority: P1) + +As a readonly-capable tenant actor, I want the latest review summary to explain key findings and accepted risks in calm language so I can understand what matters without seeing remediation or operator-only actions. + +**Why this priority**: Customer-safe review consumption is not useful if the page still looks like an operator console or hides the meaning behind the review outcome. + +**Independent Test**: Open the workspace page and the latest review detail for a tenant that has findings and accepted risks, then verify that the user can understand the current outcome without seeing create, publish, regenerate, expire, triage, or remediation controls. + +**Acceptance Scenarios**: + +1. **Given** a tenant has a published review with findings and accepted risks, **When** the actor opens the workspace page, **Then** the row or summary exposes the high-level counts and meaning of those items without requiring a drilldown first. +2. **Given** the actor opens the latest review detail from the workspace page, **When** the detail loads, **Then** the review remains read-only and does not expose admin or remediation actions the actor cannot use. +3. **Given** raw diagnostics or unrestricted audit metadata exist behind the review, **When** the actor uses the customer workspace flow, **Then** those details remain hidden from the default-visible path. + +--- + +### User Story 3 - Consume the current review pack safely (Priority: P2) + +As a readonly-capable tenant actor, I want to open or download the current review pack when it already exists so I can consume the packaged review output without triggering generation or seeing unsafe disclosure. + +**Why this priority**: Review consumption is incomplete if the user can read the summary but cannot reach the packaged artifact that already represents the customer-safe deliverable. + +**Independent Test**: From the workspace page, open a tenant that has a current review pack and verify that download works through existing access and redaction rules, while tenants without an available pack show a calm unavailable state. + +**Acceptance Scenarios**: + +1. **Given** a tenant has a current review pack and the actor has `REVIEW_PACK_VIEW`, **When** they choose the pack action, **Then** they can open or download the existing artifact without any generate or regenerate prompt. +2. **Given** a tenant has no current downloadable review pack, **When** the actor views the workspace page, **Then** the page shows that the pack is unavailable and does not offer a generation action. +3. **Given** a review pack includes redaction-safe content only, **When** the actor downloads it, **Then** the artifact and surrounding disclosure continue to honor existing redaction semantics. + +### Edge Cases + +- What happens when a tenant has a ready review but nothing published yet? The workspace page shows `No published review available yet` rather than exposing internal-only lifecycle states. +- What happens when a query parameter or remembered filter points at a tenant outside the actor's scope? The page resolves as not found for explicit tenant targeting and silently omits inaccessible tenants from broad workspace listings. +- What happens when the actor can view reviews but not review packs or evidence? The page remains usable, but pack and evidence actions are absent rather than replaced with leaking hints. +- What happens when a review pack exists but is expired or otherwise unavailable for consumption? The page shows an unavailable state and does not offer regeneration or admin recovery actions. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature does not introduce Graph calls, write/change behavior, or long-running work. It does change runtime behavior, authorization posture, disclosure rules, and audit expectations for a new read-only customer-facing surface in the existing admin plane. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature must stay derived. It must not add new persistence, new customer-state families, new publication semantics, or a parallel presenter framework. + +**Constitution alignment (XCUT-001):** The feature must extend existing review, evidence, review-pack, and artifact-truth paths rather than creating a local customer-review semantic layer. + +**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** The default path must remain customer-readable, decision-first, and free from raw diagnostics, with deeper proof only on demand. + +**Constitution alignment (TEST-GOV-001):** The implementation must add focused feature tests plus one explicit browser smoke test; no hidden heavy family may spread from this slice. + +**Constitution alignment (RBAC-UX):** Workspace and tenant membership remain deny-as-not-found boundaries; page and deep-link authorization must use canonical capability checks rather than raw role checks. + +**Constitution alignment (UI-FIL-001 / UI-NAMING-001 / DECIDE-001 / ACTSURF-001):** The new surface must remain a native Filament read-only reporting page with one dominant inspect action, one optional safe download shortcut, and no destructive or remediation controls. + +### Functional Requirements + +- **FR-001**: The system MUST provide one canonical read-only customer review workspace in the existing `/admin` plane for the current workspace. +- **FR-002**: The system MUST list only entitled tenants and MUST derive each visible row or card from existing tenant-owned review, evidence, review-pack, and findings truth. +- **FR-003**: The default-visible page MUST show the latest published customer-safe review state per entitled tenant and MUST NOT expose draft, failed, or other internal-only review states as the primary customer path. +- **FR-004**: The page MUST show, for each visible tenant, the current review outcome, latest review time, key findings summary, accepted-risk summary, and review-pack availability in calm, read-only language. +- **FR-005**: The page MUST offer a primary inspect action that opens the existing tenant-scoped review detail for the latest customer-safe review. +- **FR-006**: The page MUST allow entitled actors to open or download an existing review pack only through current `REVIEW_PACK_VIEW` access and existing redaction-safe artifact rules. +- **FR-007**: The page MUST NOT expose review generation, publication, regeneration, refresh, expire, triage, risk acceptance, remediation, or admin-setting actions. +- **FR-008**: The page and its deep links MUST enforce workspace and tenant isolation such that non-members or out-of-scope tenant targets resolve as not found. +- **FR-009**: Within an established workspace and tenant scope, optional sections and actions MUST be gated through the canonical capability registry, including `TENANT_VIEW`, `TENANT_REVIEW_VIEW`, `REVIEW_PACK_VIEW`, `EVIDENCE_VIEW`, `TENANT_FINDINGS_VIEW`, `FINDING_EXCEPTION_VIEW`, and `AUDIT_VIEW` where relevant. +- **FR-010**: The feature MUST reuse existing artifact truth and publication-readiness semantics from current review, review-pack, and evidence surfaces and MUST NOT create a separate customer-review truth model. +- **FR-011**: Raw operator diagnostics, raw JSON or provider payloads, unrestricted audit metadata, and platform-only debug semantics MUST remain out of the default-visible customer workspace path. +- **FR-012**: Explicit artifact opens or downloads exposed through this surface MUST remain auditable using the current audit infrastructure without introducing a new audit store. +- **FR-013**: When entered from a tenant-scoped review, review-pack, evidence, or related tenant context, the workspace page MUST preserve that tenant context as a safe prefilter. +- **FR-014**: When no published customer-safe review or downloadable review pack exists, the page MUST show a truthful unavailable state instead of hinting at hidden drafts, operator-only artifacts, or unavailable generation paths. + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Customer Review Workspace | new `App\Filament\Pages\Reviews\CustomerReviewWorkspace` | `Clear filters` only when a tenant or status prefilter is active | clickable row or card opening the latest tenant review | `Open latest review`, `Download review pack` when already available and permitted | none | `Clear filters` when filtered; otherwise an explanatory no-data state is allowed because the page is strictly read-only and intentionally has no create CTA | `N/A` - detail actions remain on reused tenant-scoped review and review-pack resources | `N/A` | yes - explicit artifact access and download events only | No destructive actions. No More menu required unless the implementation cannot keep open/download as the only visible actions. | + +### Key Entities *(include if feature involves data)* + +- **Customer Review Workspace Entry**: A derived workspace-scoped summary for one entitled tenant that combines the latest published tenant review, high-level findings and accepted-risk summaries, and current review-pack availability without becoming a persisted entity. +- **TenantReview**: The existing tenant-owned review artifact that anchors the latest customer-safe review state, lifecycle, executive summary, and deep-link target. +- **ReviewPack**: The existing tenant-owned downloadable artifact that packages review consumption and already carries redaction-aware access rules. +- **EvidenceSnapshot**: The existing tenant-owned supporting artifact that proves freshness and completeness when the actor explicitly drills deeper than the customer-safe default path. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: An entitled readonly-capable actor can reach the latest customer-safe review state for an entitled tenant in two steps or fewer from workspace context. +- **SC-002**: In 100% of validated readonly scenarios, the default-visible customer workspace path shows no admin, remediation, regeneration, or raw-diagnostics actions. +- **SC-003**: In 100% of validated unauthorized workspace or tenant access scenarios, the feature does not reveal another tenant's presence, review existence, or artifact availability. +- **SC-004**: For tenants with a published review and an available review pack, entitled users can open the latest review or download the pack on their first attempt without operator assistance. +- **SC-005**: For tenants without a published customer-safe review or current pack, the surface explains the absence truthfully without exposing draft-only or operator-only state. \ No newline at end of file diff --git a/specs/249-customer-review-workspace/tasks.md b/specs/249-customer-review-workspace/tasks.md new file mode 100644 index 00000000..8d684dcd --- /dev/null +++ b/specs/249-customer-review-workspace/tasks.md @@ -0,0 +1,205 @@ +--- + +description: "Task list for feature implementation" +--- + +# Tasks: Customer Review Workspace v1 + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/contracts/customer-review-workspace.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` + +**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in the narrow `confidence` lane plus one explicit `browser` smoke slice, using the targeted Sail commands already captured in the feature spec, plan, and quickstart artifacts. + +## Test Governance Notes + +- Lane assignment: `confidence` plus one explicit `browser` smoke slice are the narrowest sufficient proof for latest-published selection, deny-as-not-found boundaries, capability-gated pack access, and calm customer-safe disclosure. +- Keep new coverage inside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspace*.php` plus `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`; do not widen this slice into a new portal or customer-journey test family. +- Reuse existing workspace membership, tenant membership, published review, review-pack, evidence snapshot, finding, and finding-exception fixtures; any helper introduced for row composition or launch-context state must stay explicit and cheap by default. +- If implementation needs a bounded local read helper or a new audit action ID, record the outcome as `document-in-feature` or escalate to `follow-up-spec` in the final close-out task. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Lock the bounded slice, proof commands, and guardrail expectations before runtime edits begin. + +- [x] T001 Review the bounded slice, explicit non-goals, open planning choices, and guardrail outcomes in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/checklists/requirements.md` +- [x] T002 [P] Review the latest-published selection contract, absence-state rules, signed pack-download boundary, and audit expectations in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/data-model.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/contracts/customer-review-workspace.openapi.yaml` +- [x] T003 [P] Confirm the focused Sail/Pest commands, browser smoke command, and smoke-checklist/substitution note in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` and keep the validation plan unchanged unless touched runtime truth requires an adjacent proof file + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish the shared page shell, isolation enforcement, and query seam that every user story depends on. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [x] T004 [P] Add shared authorization coverage for workspace membership, explicit tenant-prefilter targeting, deny-as-not-found 404 boundaries, and capability-first 403 semantics in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php` +- [x] T005 Create the native read-only workspace page shell and Blade view in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, keeping it in the same reviews family as `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php` and touching explicit panel discovery only if repo verification proves the page is not auto-discovered +- [x] T006 Resolve the row-query seam by reusing or minimally extending `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php` for workspace access and latest-published-per-entitled-tenant reads; only if that seam cannot safely express the query add a bounded helper beside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and record the choice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md` +- [x] T007 [P] Thread Livewire-safe tenant prefilter, highlight, and clear-filter state through `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, reusing the current workspace-page state patterns from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php` + +**Checkpoint**: Foundation ready. The customer-safe page shell, 404/403 boundaries, and query-seam decision are in place. + +--- + +## Phase 3: User Story 1 - Open The Latest Customer-Safe Review (Priority: P1) 🎯 MVP + +**Goal**: Let a readonly-capable tenant actor open one workspace page that shows the latest published customer-safe review for each entitled tenant without surfacing internal-only review states. + +**Independent Test**: Sign in as a readonly-capable tenant actor, open `/admin/reviews/workspace`, and confirm each visible tenant shows only its latest published review summary while tenants without a published review show a truthful absence state. + +### Tests for User Story 1 + +- [x] T008 [P] [US1] Add workspace page feature coverage for latest published review selection, tenant launch-context highlighting, and truthful no-published-review absence handling in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php` + +### Implementation for User Story 1 + +- [x] T009 [US1] Compose one derived workspace entry per entitled tenant from existing `TenantReview`, `currentExportReviewPack`, and `evidenceSnapshot` truth in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php` or the bounded helper chosen in T006 +- [x] T010 [US1] Add or reuse safe customer-workspace launch links from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, and the nearest tenant dashboard review entry surface under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Widgets/Tenant/` so tenant context arrives as a safe prefilter without creating a second summary shell +- [x] T011 [US1] Render the calm row summary and route the dominant `Open latest review` affordance through the existing tenant review detail path in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php` +- [x] T012 [US1] Keep tenants without a published review visible only as truthful absence states and never as draft, ready, failed, or internal-only fallbacks in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` + +**Checkpoint**: User Story 1 is independently functional when the workspace page truthfully selects the latest published review and handles no-published-review tenants safely. + +--- + +## Phase 4: User Story 2 - Understand Findings And Accepted Risks Without Admin Controls (Priority: P1) + +**Goal**: Let a readonly-capable tenant actor understand key findings and accepted risks from the latest review in calm language without seeing remediation, publishing, or debug controls. + +**Independent Test**: Open the workspace page and the linked latest review detail for a tenant with findings and accepted risks, then confirm the actor can understand the review outcome without seeing admin or remediation actions. + +### Tests for User Story 2 + +- [x] T013 [P] [US2] Extend workspace page feature coverage for key-finding and accepted-risk summaries, hidden raw/support detail by default, and absent admin or remediation controls on the workspace page in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php` +- [x] T014 [P] [US2] Add browser smoke coverage for calm default-visible content, one dominant `Open latest review` action, safe secondary actions, and absent admin or remediation controls in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` +- [x] T015 [P] [US2] Extend the smallest existing tenant-review detail readonly or action-surface test under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/` after repo verification so the workspace launch path proves detail inspection stays read-only for readonly-capable actors + +### Implementation for User Story 2 + +- [x] T016 [US2] Render key-finding and accepted-risk summaries by reusing review summary and section payloads from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewService.php` together with `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/RedactionIntegrity.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, extending shared helpers only if repo verification shows a missing customer-safe summary field +- [x] T017 [US2] Keep default-visible content limited to customer-safe outcome, findings, accepted risks, freshness context, and explicit secondary proof links while excluding raw JSON, unrestricted audit metadata, and diagnostics from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` +- [x] T018 [US2] If the workspace-to-detail handoff exposes any admin, remediation, publish, regenerate, expire, triage, or exception-edit controls to readonly-capable actors, tighten the smallest existing tenant-review detail surface in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php` or its matching page class after repo verification instead of adding a second customer-detail shell + +**Checkpoint**: User Story 2 is independently functional when summaries stay calm, raw detail stays secondary, and readonly actors never see admin or remediation controls in the customer-safe flow. + +--- + +## Phase 5: User Story 3 - Consume The Current Review Pack Safely (Priority: P2) + +**Goal**: Let a readonly-capable tenant actor open or download the current review pack when it already exists, while keeping unavailable states calm and preserving signed-download safety. + +**Independent Test**: From the workspace page, use the pack action for a tenant with a current pack and for one without a current pack, then confirm only the existing safe download path is exposed and no generation or regeneration flow appears. + +### Tests for User Story 3 + +- [x] T019 [P] [US3] Add review-pack access feature coverage for visible download action only with `REVIEW_PACK_VIEW`, calm unavailable state when no current pack exists, preserved signed download behavior, and truthful audit reuse or additive action-ID wiring in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php` +- [x] T020 [P] [US3] If workspace implementation touches pack-download plumbing, extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php` to prove no generate or regenerate path was introduced; otherwise leave pack-download regression coverage unchanged and record that outcome in the final close-out task + +### Implementation for User Story 3 + +- [x] T021 [US3] Surface current review-pack availability and the one safe inline `Download review pack` shortcut from the existing current-export relation and signed route semantics in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/ReviewPackService.php` +- [x] T022 [US3] Keep review-pack and evidence secondary actions capability-gated through existing `REVIEW_PACK_VIEW` and `EVIDENCE_VIEW` checks plus the current resource route helpers in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` +- [x] T023 [US3] Reuse the existing audit pipeline for explicit artifact open or download events surfaced by the workspace page, adding a stable `AuditActionId` and `WorkspaceAuditLogger` wiring only if repo verification shows the current review or pack path does not already emit a truthful event in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Audit/AuditActionId.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, and the smallest calling surface selected during implementation + +**Checkpoint**: User Story 3 is independently functional when pack visibility and download stay capability-gated, unavailable states stay calm, and audit reuse remains bounded. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Run the focused validation suite, capture executed smoke evidence, format touched files, and record the feature-local close-out without widening scope. + +- [x] T024 Run the targeted workspace-page Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php` +- [x] T025 Run the targeted authorization Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php` +- [x] T026 Run the targeted pack-access Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php` if T020 touched that file +- [x] T027 Run the explicit browser smoke command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` +- [x] T028 Satisfy the smoke-evidence checklist in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` through either a human manual run or an explicitly documented bounded browser-smoke substitution for readonly workspace entry, tenant-prefilter launch, read-only review detail open, pack available or unavailable behavior, and out-of-scope tenant targeting +- [x] T029 Run dirty-only Pint through Sail for touched platform files using the command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` +- [x] T030 Record the final `Guardrail / Exception / Smoke Coverage` close-out, lane results, executed smoke-evidence outcome, review outcome class (`acceptable-special-case` unless implementation proves otherwise), workflow outcome (`keep` unless implementation proves otherwise), and any bounded `document-in-feature` note for the `TenantReviewRegisterService` versus local-helper choice or audit-action wiring in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/checklists/requirements.md` + +## Close-out Notes + +- T006 reused `TenantReviewRegisterService` for workspace entitlement and latest-published-per-tenant reads; no page-local helper was introduced. +- T010 landed direct customer-workspace launch links on tenant review detail, review-pack detail, evidence related context, and the tenant review-pack widget. `ReviewRegister` and `EvidenceOverview` satisfied the task through existing row/detail navigation reuse rather than new duplicate launch buttons. +- T018 was closed by making the tenant-review detail route enter a customer-safe read-only mode when launched from the workspace path, leaving the normal operator detail route behavior unchanged. +- T020 touched pack-download plumbing. `ReviewPackDownloadTest.php` and `ReviewPackRbacTest.php` were updated and passed after capability enforcement and audit logging were added to the signed download route. +- T023 reused the existing audit store and `WorkspaceAuditLogger` with additive `tenant_review.opened` and `review_pack.downloaded` action IDs; no new audit store or parallel audit pipeline was introduced. +- T028 used the bounded Pest browser smoke plus the focused feature lane as the executed smoke evidence. No separate human integrated-browser manual smoke run was performed. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: starts immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories until the page shell, auth boundaries, and query-seam choice are in place. +- **Phase 3 (US1)**: depends on Phase 2 and establishes the MVP customer-safe workspace path. +- **Phase 4 (US2)**: depends on Phase 2 and is safest after US1 because both stories extend the same page and view surfaces. +- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 because pack actions and audit reuse build on the same workspace rows. +- **Phase 6 (Polish)**: depends on every implemented story. + +### User Story Dependencies + +- **US1 (P1)**: first independently shippable increment once Phase 2 is complete. +- **US2 (P1)**: independently testable after Phase 2, but should merge after US1 because the same page and view files are shared hotspots. +- **US3 (P2)**: independently testable after Phase 2, but should merge after US1 because pack actions depend on the same workspace row composition. + +### Within Each User Story + +- Write the listed feature and browser coverage first and make it fail for the intended gap before implementation. +- Resolve shared service or route-helper decisions before widening the page view for that story. +- Re-run the narrowest relevant proof command after each story checkpoint before moving to the next story. + +--- + +## Parallel Opportunities + +### Phase 1 + +- T002 and T003 can run in parallel after T001 confirms the bounded slice. + +### Phase 2 + +- T004 and T005 can run in parallel. +- After T005 establishes the page shell, T006 and T007 can proceed in parallel because the query seam and page-state plumbing touch different primary files. + +### User Story 1 + +- T008 can run before implementation while T009 and T010 are split across service and entry-link work. +- T011 should follow T009 and T010 because the absence state depends on the final row composition. + +### User Story 2 + +- T013, T014, and T015 can run in parallel. +- After the tests exist, T016 and T017 can overlap before T018 checks whether the reused detail surface needs a bounded hardening pass. + +### User Story 3 + +- T019 and T020 can run in parallel. +- After pack-access tests are in place, T021 and T022 can overlap before T023 finalizes audit reuse or additive wiring. + +--- + +## Implementation Strategy + +### Suggested MVP Scope + +- MVP = **Phase 2 + User Story 1** only. That delivers the canonical read-only workspace page, the latest-published selection rule, tenant-prefilter entry, and truthful no-published-review handling without widening into summary hardening or pack-specific follow-up. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 and validate the customer-safe workspace path. +3. Deliver US2 and validate findings, accepted-risk summaries, and absence of admin controls. +4. Deliver US3 and validate pack visibility, download safety, and audit reuse. +5. Finish with Phase 6 validation, executed smoke evidence, formatting, and close-out recording. + +### Team Strategy + +1. Finish Phase 2 together before splitting story work. +2. Parallelize test authoring inside each story before converging on the shared page and view files. +3. Sequence merges touching `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` story-by-story because they are the main conflict hotspots for this slice. \ No newline at end of file -- 2.45.2 From 72bfb37ba72e9f7b923d8b577a756f35abc8651f Mon Sep 17 00:00:00 2001 From: ahmido Date: Tue, 28 Apr 2026 10:13:09 +0000 Subject: [PATCH 24/36] feat: add decision-based governance inbox (#291) ## Summary - add a read-first governance inbox page at `/admin/governance/inbox` - aggregate assigned findings, intake, stale operations, alert-delivery failures, and review follow-up into one canonical routing surface - add focused coverage for inbox authorization, navigation context, page behavior, and section builder logic - include the Spec Kit artifacts for spec 250 ## Notes - branch is synced with `dev` - this PR supersedes #290 for the governance inbox work Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/291 --- .../Pages/Governance/GovernanceInbox.php | 494 ++++++++++ .../Providers/Filament/AdminPanelProvider.php | 2 + .../GovernanceInboxSectionBuilder.php | 888 ++++++++++++++++++ .../governance/governance-inbox.blade.php | 164 ++++ .../GovernanceInboxAuthorizationTest.php | 99 ++ .../GovernanceInboxNavigationContextTest.php | 64 ++ .../Governance/GovernanceInboxPageTest.php | 143 +++ .../CommandModelSmokeTest.php | 7 + .../GovernanceInboxSectionBuilderTest.php | 197 ++++ docker-compose.yml | 2 +- .../checklists/requirements.md | 70 ++ .../contracts/governance-inbox.openapi.yaml | 159 ++++ .../data-model.md | 103 ++ specs/250-decision-governance-inbox/plan.md | 305 ++++++ .../quickstart.md | 65 ++ .../250-decision-governance-inbox/research.md | 104 ++ specs/250-decision-governance-inbox/spec.md | 294 ++++++ specs/250-decision-governance-inbox/tasks.md | 173 ++++ 18 files changed, 3332 insertions(+), 1 deletion(-) create mode 100644 apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php create mode 100644 apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php create mode 100644 apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php create mode 100644 apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php create mode 100644 apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php create mode 100644 apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php create mode 100644 apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php create mode 100644 specs/250-decision-governance-inbox/checklists/requirements.md create mode 100644 specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml create mode 100644 specs/250-decision-governance-inbox/data-model.md create mode 100644 specs/250-decision-governance-inbox/plan.md create mode 100644 specs/250-decision-governance-inbox/quickstart.md create mode 100644 specs/250-decision-governance-inbox/research.md create mode 100644 specs/250-decision-governance-inbox/spec.md create mode 100644 specs/250-decision-governance-inbox/tasks.md diff --git a/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php b/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php new file mode 100644 index 00000000..7069a0ae --- /dev/null +++ b/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php @@ -0,0 +1,494 @@ +|null + */ + private ?array $authorizedTenants = null; + + /** + * @var array|null + */ + private ?array $visibleFindingTenants = null; + + /** + * @var array|null + */ + private ?array $reviewTenants = null; + + /** + * @var array|null + */ + private ?array $inboxPayload = null; + + /** + * @var array|null + */ + private ?array $unfilteredInboxPayload = null; + + private ?Workspace $workspace = null; + + private ?bool $visibleAlertsFamily = null; + + public ?int $tenantId = null; + + public ?string $family = null; + + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the workspace decision surface calm and read-only.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The governance inbox routes into existing source surfaces instead of exposing row-level secondary actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The governance inbox does not expose bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty states stay calm and capability-safe when no visible attention exists.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'The governance inbox owns no local detail surface in v1.'); + } + + public function mount(): void + { + $this->authorizeWorkspaceMembership(); + $this->applyRequestedTenantPrefilter(); + $this->family = $this->resolveRequestedFamily(); + $this->ensureAtLeastOneVisibleFamily(); + $this->ensureRequestedFamilyIsVisible(); + } + + /** + * @return array + */ + public function appliedScope(): array + { + $selectedTenant = $this->selectedTenant(); + $availableFamilies = collect($this->availableFamilies()) + ->keyBy('key'); + + return [ + 'workspace_label' => $this->workspace()?->name, + 'tenant_label' => $selectedTenant?->name, + 'tenant_prefilter_source' => $selectedTenant instanceof Tenant ? 'explicit_filter' : 'none', + 'family_key' => $this->family, + 'family_label' => $this->family !== null + ? ($availableFamilies->get($this->family)['label'] ?? Str::headline($this->family)) + : 'All attention', + 'total_count' => (int) ($this->inboxPayload()['total_count'] ?? 0), + ]; + } + + /** + * @return list + */ + public function availableFamilies(): array + { + return $this->inboxPayload()['available_families'] ?? []; + } + + /** + * @return list> + */ + public function sections(): array + { + return $this->inboxPayload()['sections'] ?? []; + } + + /** + * @return array + */ + public function calmEmptyState(): array + { + if ($this->tenantFilterAloneExcludesRows()) { + return [ + 'title' => 'This tenant filter is hiding other visible attention', + 'body' => 'The current tenant scope is calm, but other visible tenants in this workspace still have governance attention.', + 'action_label' => 'Clear tenant filter', + 'action_url' => $this->pageUrl(['tenant' => null]), + ]; + } + + return [ + 'title' => 'No visible governance attention right now', + 'body' => 'The current workspace scope is calm across the visible governance families.', + 'action_label' => null, + 'action_url' => null, + ]; + } + + public function hasTenantPrefilter(): bool + { + return $this->selectedTenant() instanceof Tenant; + } + + public function isActiveFamily(?string $familyKey): bool + { + return $this->family === $familyKey; + } + + public function pageUrl(array $overrides = []): string + { + $selectedTenant = $this->selectedTenant(); + $resolvedTenant = array_key_exists('tenant', $overrides) + ? $overrides['tenant'] + : ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null); + $resolvedFamily = array_key_exists('family', $overrides) + ? $overrides['family'] + : $this->family; + + return static::getUrl( + panel: 'admin', + parameters: array_filter([ + 'tenant_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null, + 'family' => is_string($resolvedFamily) && $resolvedFamily !== '' ? $resolvedFamily : null, + ], static fn (mixed $value): bool => $value !== null && $value !== ''), + ); + } + + public function navigationContext(): CanonicalNavigationContext + { + return new CanonicalNavigationContext( + sourceSurface: 'governance.inbox', + canonicalRouteName: static::getRouteName(Filament::getPanel('admin')), + tenantId: $this->tenantId, + backLinkLabel: 'Back to governance inbox', + backLinkUrl: $this->pageUrl(), + ); + } + + private function authorizeWorkspaceMembership(): void + { + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User) { + abort(403); + } + + if (! $workspace instanceof Workspace) { + throw new NotFoundHttpException; + } + + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + throw new NotFoundHttpException; + } + } + + private function ensureAtLeastOneVisibleFamily(): void + { + if ( + $this->hasVisibleOperationsFamily() + || $this->visibleFindingTenants() !== [] + || $this->reviewTenants() !== [] + || $this->hasVisibleAlertsFamily() + ) { + return; + } + + abort(403); + } + + private function ensureRequestedFamilyIsVisible(): void + { + if ($this->family === null) { + return; + } + + if (in_array($this->family, collect($this->availableFamilies())->pluck('key')->all(), true)) { + return; + } + + throw new NotFoundHttpException; + } + + private function hasVisibleOperationsFamily(): bool + { + return $this->authorizedTenants() !== []; + } + + private function hasVisibleAlertsFamily(): bool + { + if (is_bool($this->visibleAlertsFamily)) { + return $this->visibleAlertsFamily; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->visibleAlertsFamily = false; + } + + return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW); + } + + /** + * @return array + */ + private function visibleFindingTenants(): array + { + if ($this->visibleFindingTenants !== null) { + return $this->visibleFindingTenants; + } + + $user = auth()->user(); + $tenants = $this->authorizedTenants(); + + if (! $user instanceof User || $tenants === []) { + return $this->visibleFindingTenants = []; + } + + $resolver = app(CapabilityResolver::class); + $resolver->primeMemberships( + $user, + array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants), + ); + + return $this->visibleFindingTenants = array_values(array_filter( + $tenants, + fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW), + )); + } + + /** + * @return array + */ + private function reviewTenants(): array + { + if ($this->reviewTenants !== null) { + return $this->reviewTenants; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->reviewTenants = []; + } + + $service = app(TenantReviewRegisterService::class); + + if (! $service->canAccessWorkspace($user, $workspace)) { + return $this->reviewTenants = []; + } + + return $this->reviewTenants = $service->authorizedTenants($user, $workspace); + } + + /** + * @return array + */ + private function authorizedTenants(): array + { + if ($this->authorizedTenants !== null) { + return $this->authorizedTenants; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->authorizedTenants = []; + } + + return $this->authorizedTenants = $user->tenants() + ->where('tenants.workspace_id', (int) $workspace->getKey()) + ->where('tenants.status', 'active') + ->orderBy('tenants.name') + ->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id']) + ->all(); + } + + private function applyRequestedTenantPrefilter(): void + { + $requestedTenant = request()->query('tenant_id', request()->query('tenant')); + + if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) { + return; + } + + foreach ($this->authorizedTenants() as $tenant) { + if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) { + continue; + } + + $this->tenantId = (int) $tenant->getKey(); + + return; + } + + throw new NotFoundHttpException; + } + + private function resolveRequestedFamily(): ?string + { + $family = request()->query('family'); + + if (! is_string($family)) { + return null; + } + + return in_array($family, [ + 'assigned_findings', + 'intake_findings', + 'stale_operations', + 'alert_delivery_failures', + 'review_follow_up', + ], true) ? $family : null; + } + + 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 array + */ + private function inboxPayload(): array + { + if (is_array($this->inboxPayload)) { + return $this->inboxPayload; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->inboxPayload = [ + 'sections' => [], + 'available_families' => [], + 'family_counts' => [], + 'total_count' => 0, + ]; + } + + return $this->inboxPayload = app(GovernanceInboxSectionBuilder::class)->build( + user: $user, + workspace: $workspace, + authorizedTenants: $this->authorizedTenants(), + visibleFindingTenants: $this->visibleFindingTenants(), + reviewTenants: $this->reviewTenants(), + canViewAlerts: $this->hasVisibleAlertsFamily(), + selectedTenant: $this->selectedTenant(), + selectedFamily: $this->family, + navigationContext: $this->navigationContext(), + ); + } + + /** + * @return array + */ + private function unfilteredInboxPayload(): array + { + if (is_array($this->unfilteredInboxPayload)) { + return $this->unfilteredInboxPayload; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->unfilteredInboxPayload = [ + 'sections' => [], + 'available_families' => [], + 'family_counts' => [], + 'total_count' => 0, + ]; + } + + return $this->unfilteredInboxPayload = app(GovernanceInboxSectionBuilder::class)->build( + user: $user, + workspace: $workspace, + authorizedTenants: $this->authorizedTenants(), + visibleFindingTenants: $this->visibleFindingTenants(), + reviewTenants: $this->reviewTenants(), + canViewAlerts: $this->hasVisibleAlertsFamily(), + selectedTenant: null, + selectedFamily: null, + navigationContext: $this->navigationContext(), + ); + } + + private function selectedTenant(): ?Tenant + { + if (! is_int($this->tenantId)) { + return null; + } + + foreach ($this->authorizedTenants() as $tenant) { + if ((int) $tenant->getKey() === $this->tenantId) { + return $tenant; + } + } + + return null; + } + + private function tenantFilterAloneExcludesRows(): bool + { + if (! is_int($this->tenantId) || $this->family !== null) { + return false; + } + + if ($this->sections() !== []) { + return false; + } + + return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0; + } +} \ No newline at end of file diff --git a/apps/platform/app/Providers/Filament/AdminPanelProvider.php b/apps/platform/app/Providers/Filament/AdminPanelProvider.php index 3ba79958..29da07f0 100644 --- a/apps/platform/app/Providers/Filament/AdminPanelProvider.php +++ b/apps/platform/app/Providers/Filament/AdminPanelProvider.php @@ -7,6 +7,7 @@ use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\Findings\FindingsHygieneReport; use App\Filament\Pages\Findings\FindingsIntakeQueue; +use App\Filament\Pages\Governance\GovernanceInbox; use App\Filament\Pages\Findings\MyFindingsInbox; use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\Monitoring\FindingExceptionsQueue; @@ -180,6 +181,7 @@ public function panel(Panel $panel): Panel InventoryCoverage::class, TenantRequiredPermissions::class, WorkspaceSettings::class, + GovernanceInbox::class, FindingsHygieneReport::class, FindingsIntakeQueue::class, MyFindingsInbox::class, diff --git a/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php b/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php new file mode 100644 index 00000000..bc8e778d --- /dev/null +++ b/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php @@ -0,0 +1,888 @@ + + */ + private const FAMILY_ORDER = [ + 'assigned_findings', + 'intake_findings', + 'stale_operations', + 'alert_delivery_failures', + 'review_follow_up', + ]; + + public function __construct( + private TenantBackupHealthResolver $backupHealthResolver, + private RestoreSafetyResolver $restoreSafetyResolver, + private TenantTriageReviewStateResolver $tenantTriageReviewStateResolver, + private TenantReviewRegisterService $tenantReviewRegisterService, + ) {} + + /** + * @param array $authorizedTenants + * @param array $visibleFindingTenants + * @param array $reviewTenants + * @return array{ + * sections: list>, + * available_families: list, + * family_counts: array, + * total_count: int, + * } + */ + public function build( + User $user, + Workspace $workspace, + array $authorizedTenants, + array $visibleFindingTenants, + array $reviewTenants, + bool $canViewAlerts, + ?Tenant $selectedTenant = null, + ?string $selectedFamily = null, + ?CanonicalNavigationContext $navigationContext = null, + ): array { + $authorizedTenantsById = $this->indexTenants($authorizedTenants); + $visibleFindingTenantsById = $this->indexTenants($visibleFindingTenants); + $reviewTenantsById = $this->indexTenants($reviewTenants); + + $allSections = []; + $availableFamilies = []; + $familyCounts = []; + + if ($visibleFindingTenantsById !== []) { + $assignedSection = $this->assignedFindingsSection( + user: $user, + visibleFindingTenants: $visibleFindingTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$assignedSection['key']] = $assignedSection; + $availableFamilies[] = [ + 'key' => $assignedSection['key'], + 'label' => $assignedSection['label'], + 'count' => $assignedSection['count'], + ]; + $familyCounts[$assignedSection['key']] = $assignedSection['count']; + + $intakeSection = $this->intakeFindingsSection( + visibleFindingTenants: $visibleFindingTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$intakeSection['key']] = $intakeSection; + $availableFamilies[] = [ + 'key' => $intakeSection['key'], + 'label' => $intakeSection['label'], + 'count' => $intakeSection['count'], + ]; + $familyCounts[$intakeSection['key']] = $intakeSection['count']; + } + + if ($authorizedTenantsById !== []) { + $operationsSection = $this->operationsSection( + workspace: $workspace, + authorizedTenants: $authorizedTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$operationsSection['key']] = $operationsSection; + $availableFamilies[] = [ + 'key' => $operationsSection['key'], + 'label' => $operationsSection['label'], + 'count' => $operationsSection['count'], + ]; + $familyCounts[$operationsSection['key']] = $operationsSection['count']; + } + + if ($canViewAlerts) { + $alertsSection = $this->alertsSection( + workspace: $workspace, + authorizedTenants: $authorizedTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$alertsSection['key']] = $alertsSection; + $availableFamilies[] = [ + 'key' => $alertsSection['key'], + 'label' => $alertsSection['label'], + 'count' => $alertsSection['count'], + ]; + $familyCounts[$alertsSection['key']] = $alertsSection['count']; + } + + if ($reviewTenantsById !== []) { + $reviewSection = $this->reviewFollowUpSection( + user: $user, + workspace: $workspace, + reviewTenants: $reviewTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$reviewSection['key']] = $reviewSection; + $availableFamilies[] = [ + 'key' => $reviewSection['key'], + 'label' => $reviewSection['label'], + 'count' => $reviewSection['count'], + ]; + $familyCounts[$reviewSection['key']] = $reviewSection['count']; + } + + $sections = []; + + foreach (self::FAMILY_ORDER as $familyKey) { + $section = $allSections[$familyKey] ?? null; + + if (! is_array($section)) { + continue; + } + + if ($selectedFamily !== null) { + if ($familyKey === $selectedFamily) { + $sections[] = $section; + } + + continue; + } + + if ((int) ($section['count'] ?? 0) > 0) { + $sections[] = $section; + } + } + + return [ + 'sections' => $sections, + 'available_families' => $availableFamilies, + 'family_counts' => $familyCounts, + 'total_count' => array_sum($familyCounts), + ]; + } + + /** + * @param array $tenants + * @return array + */ + private function indexTenants(array $tenants): array + { + $indexed = []; + + foreach ($tenants as $tenant) { + $indexed[(int) $tenant->getKey()] = $tenant; + } + + return $indexed; + } + + /** + * @param array $visibleFindingTenants + * @return array + */ + private function assignedFindingsSection( + User $user, + array $visibleFindingTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $baseQuery = $this->assignedFindingsQuery($user, $visibleFindingTenants, $selectedTenant); + $count = (clone $baseQuery)->count(); + $overdueCount = (clone $baseQuery) + ->whereNotNull('due_at') + ->where('due_at', '<', now()) + ->count(); + $entries = $this->orderedAssignedFindingsQuery(clone $baseQuery) + ->limit(self::PREVIEW_LIMIT) + ->get() + ->map(fn (Finding $finding): array => $this->findingEntry($finding, 'assigned_findings', $navigationContext, 10)) + ->all(); + + return [ + 'key' => 'assigned_findings', + 'label' => 'Assigned findings', + 'count' => $count, + 'summary' => $this->assignedFindingsSummary($count, $overdueCount), + 'dominant_action_label' => 'Open my findings', + 'dominant_action_url' => $this->appendQuery( + MyFindingsInbox::getUrl( + panel: 'admin', + parameters: array_filter([ + 'tenant' => $selectedTenant?->external_id, + ], static fn (mixed $value): bool => is_string($value) && $value !== ''), + ), + $navigationContext?->toQuery() ?? [], + ), + 'entries' => $entries, + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No assigned findings match this tenant filter right now.' + : 'No assigned findings are visible right now.', + ]; + } + + /** + * @param array $visibleFindingTenants + * @return array + */ + private function intakeFindingsSection( + array $visibleFindingTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $baseQuery = $this->intakeFindingsQuery($visibleFindingTenants, $selectedTenant); + $count = (clone $baseQuery)->count(); + $needsTriageCount = (clone $baseQuery) + ->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED]) + ->count(); + $entries = $this->orderedIntakeFindingsQuery(clone $baseQuery) + ->limit(self::PREVIEW_LIMIT) + ->get() + ->map(fn (Finding $finding): array => $this->findingEntry($finding, 'intake_findings', $navigationContext, 20)) + ->all(); + + return [ + 'key' => 'intake_findings', + 'label' => 'Findings intake', + 'count' => $count, + 'summary' => $this->intakeFindingsSummary($count, $needsTriageCount), + 'dominant_action_label' => 'Open findings intake', + 'dominant_action_url' => $this->appendQuery( + FindingsIntakeQueue::getUrl( + panel: 'admin', + parameters: array_filter([ + 'tenant' => $selectedTenant?->external_id, + 'view' => $needsTriageCount > 0 ? 'needs_triage' : null, + ], static fn (mixed $value): bool => $value !== null && $value !== ''), + ), + $navigationContext?->toQuery() ?? [], + ), + 'entries' => $entries, + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No intake findings match this tenant filter right now.' + : 'No intake findings are visible right now.', + ]; + } + + /** + * @param array $authorizedTenants + * @return array + */ + private function operationsSection( + Workspace $workspace, + array $authorizedTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $terminalQuery = $this->terminalOperationsQuery($workspace, $authorizedTenants, $selectedTenant); + $staleQuery = $this->staleOperationsQuery($workspace, $authorizedTenants, $selectedTenant); + $terminalCount = (clone $terminalQuery)->count(); + $staleCount = (clone $staleQuery)->count(); + $entries = array_merge( + (clone $terminalQuery)->latest('completed_at')->latest('id')->limit(self::PREVIEW_LIMIT)->get()->all(), + (clone $staleQuery)->latest('created_at')->latest('id')->limit(self::PREVIEW_LIMIT)->get()->all(), + ); + $entries = collect($entries) + ->unique(fn (OperationRun $run): int => (int) $run->getKey()) + ->sortBy([ + fn (OperationRun $run): int => $run->problemClass() === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP ? 0 : 1, + fn (OperationRun $run): int => -1 * (int) $run->getKey(), + ]) + ->take(self::PREVIEW_LIMIT) + ->map(fn (OperationRun $run): array => $this->operationEntry($run, $navigationContext)) + ->values() + ->all(); + $dominantProblemClass = $terminalCount > 0 + ? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP + : OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION; + + return [ + 'key' => 'stale_operations', + 'label' => 'Operations follow-up', + 'count' => $terminalCount + $staleCount, + 'summary' => $this->operationsSummary($terminalCount, $staleCount), + 'dominant_action_label' => $terminalCount > 0 ? 'Open terminal follow-up' : 'Open stale operations', + 'dominant_action_url' => OperationRunLinks::index( + tenant: $selectedTenant, + context: $navigationContext, + problemClass: $dominantProblemClass, + ), + 'entries' => $entries, + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No stale or terminal follow-up operations match this tenant filter right now.' + : 'No stale or terminal follow-up operations are visible right now.', + ]; + } + + /** + * @param array $authorizedTenants + * @return array + */ + private function alertsSection( + Workspace $workspace, + array $authorizedTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $baseQuery = $this->alertsQuery($workspace, $authorizedTenants, $selectedTenant); + $count = (clone $baseQuery)->count(); + $entries = (clone $baseQuery) + ->latest('created_at') + ->latest('id') + ->limit(self::PREVIEW_LIMIT) + ->get() + ->map(fn (AlertDelivery $delivery): array => $this->alertEntry($delivery, $navigationContext)) + ->all(); + + return [ + 'key' => 'alert_delivery_failures', + 'label' => 'Alert delivery failures', + 'count' => $count, + 'summary' => $this->alertsSummary($count), + 'dominant_action_label' => 'Open alert deliveries', + 'dominant_action_url' => $this->appendQuery( + AlertDeliveryResource::getUrl(panel: 'admin'), + array_replace_recursive( + $navigationContext?->toQuery() ?? [], + [ + 'tableFilters' => array_filter([ + 'status' => ['value' => AlertDelivery::STATUS_FAILED], + 'tenant_id' => $selectedTenant instanceof Tenant + ? ['value' => (string) $selectedTenant->getKey()] + : null, + ], static fn (mixed $value): bool => $value !== null), + ], + ), + ), + 'entries' => $entries, + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No failed alert deliveries match this tenant filter right now.' + : 'No failed alert deliveries are visible right now.', + ]; + } + + /** + * @param array $reviewTenants + * @return array + */ + private function reviewFollowUpSection( + User $user, + Workspace $workspace, + array $reviewTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $tenantIds = $selectedTenant instanceof Tenant + ? [(int) $selectedTenant->getKey()] + : array_keys($reviewTenants); + $backupHealthByTenant = $this->backupHealthResolver->assessMany($tenantIds); + $recoveryEvidenceByTenant = $this->restoreSafetyResolver->dashboardRecoveryEvidenceForTenants($tenantIds, $backupHealthByTenant); + $resolved = $this->tenantTriageReviewStateResolver->resolveMany( + workspaceId: (int) $workspace->getKey(), + tenantIds: $tenantIds, + backupHealthByTenant: $backupHealthByTenant, + recoveryEvidenceByTenant: $recoveryEvidenceByTenant, + ); + $latestPublishedReviews = $this->tenantReviewRegisterService + ->latestPublishedQuery($user, $workspace) + ->get() + ->keyBy('tenant_id') + ->all(); + + $rawEntries = []; + + foreach ($tenantIds as $tenantId) { + $tenant = $reviewTenants[$tenantId] ?? null; + $rows = $resolved['rows'][$tenantId] ?? null; + + if (! $tenant instanceof Tenant || ! is_array($rows)) { + continue; + } + + foreach ([PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE] as $family) { + $row = $rows[$family] ?? null; + + if (! is_array($row) || ($row['current_concern_present'] ?? false) !== true) { + continue; + } + + $derivedState = $row['derived_state'] ?? null; + + if (! in_array($derivedState, [ + TenantTriageReview::STATE_FOLLOW_UP_NEEDED, + TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW, + ], true)) { + continue; + } + + $rawEntries[] = $this->reviewEntry( + tenant: $tenant, + family: $family, + row: $row, + latestPublishedReview: $latestPublishedReviews[$tenantId] ?? null, + navigationContext: $navigationContext, + ); + } + } + + usort($rawEntries, function (array $left, array $right): int { + $leftRank = (int) ($left['urgency_rank'] ?? 0); + $rightRank = (int) ($right['urgency_rank'] ?? 0); + + if ($leftRank !== $rightRank) { + return $leftRank <=> $rightRank; + } + + return strcmp((string) ($left['headline'] ?? ''), (string) ($right['headline'] ?? '')); + }); + + $followUpCount = collect($rawEntries) + ->where('status_label', 'Follow-up needed') + ->count(); + $changedCount = collect($rawEntries) + ->where('status_label', 'Changed since review') + ->count(); + + return [ + 'key' => 'review_follow_up', + 'label' => 'Review follow-up', + 'count' => count($rawEntries), + 'summary' => $this->reviewSummary($followUpCount, $changedCount), + 'dominant_action_label' => 'Open review follow-up', + 'dominant_action_url' => $selectedTenant instanceof Tenant + ? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? []) + : $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive( + $navigationContext?->toQuery() ?? [], + [ + 'backup_posture' => [ + TenantBackupHealthAssessment::POSTURE_ABSENT, + TenantBackupHealthAssessment::POSTURE_STALE, + TenantBackupHealthAssessment::POSTURE_DEGRADED, + ], + 'recovery_evidence' => [ + TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED, + TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED, + ], + 'review_state' => [ + TenantTriageReview::STATE_FOLLOW_UP_NEEDED, + TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW, + ], + 'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST, + ], + )), + 'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT), + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No review follow-up is visible for this tenant filter right now.' + : 'No review follow-up is visible right now.', + ]; + } + + /** + * @param array $visibleFindingTenants + */ + private function assignedFindingsQuery(User $user, array $visibleFindingTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + $tenantIds = $selectedTenant instanceof Tenant + ? [(int) $selectedTenant->getKey()] + : array_keys($visibleFindingTenants); + + return Finding::query() + ->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name']) + ->withSubjectDisplayName() + ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) + ->where('assignee_user_id', (int) $user->getKey()) + ->whereIn('status', Finding::openStatusesForQuery()); + } + + private function orderedAssignedFindingsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder + { + return $query + ->orderByRaw( + 'case when due_at is not null and due_at < ? then 0 when reopened_at is not null then 1 else 2 end asc', + [now()], + ) + ->orderByRaw('case when due_at is null then 1 else 0 end asc') + ->orderBy('due_at') + ->orderByDesc('id'); + } + + /** + * @param array $visibleFindingTenants + */ + private function intakeFindingsQuery(array $visibleFindingTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + $tenantIds = $selectedTenant instanceof Tenant + ? [(int) $selectedTenant->getKey()] + : array_keys($visibleFindingTenants); + + return Finding::query() + ->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name']) + ->withSubjectDisplayName() + ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) + ->whereNull('assignee_user_id') + ->whereIn('status', Finding::openStatusesForQuery()); + } + + private function orderedIntakeFindingsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder + { + return $query + ->orderByRaw( + "case + when due_at is not null and due_at < ? then 0 + when status = ? then 1 + when status = ? then 2 + else 3 + end asc", + [now(), Finding::STATUS_REOPENED, Finding::STATUS_NEW], + ) + ->orderByRaw('case when due_at is null then 1 else 0 end asc') + ->orderBy('due_at') + ->orderByDesc('id'); + } + + /** + * @param array $authorizedTenants + */ + private function terminalOperationsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant) + ->terminalFollowUp(); + } + + /** + * @param array $authorizedTenants + */ + private function staleOperationsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant) + ->activeStaleAttention(); + } + + /** + * @param array $authorizedTenants + */ + private function operationsBaseQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + $tenantIds = array_keys($authorizedTenants); + + return OperationRun::query() + ->with('tenant') + ->where('workspace_id', (int) $workspace->getKey()) + ->where(function ($query) use ($selectedTenant, $tenantIds): void { + if ($selectedTenant instanceof Tenant) { + $query->where('tenant_id', (int) $selectedTenant->getKey()); + + return; + } + + $query + ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) + ->orWhereNull('tenant_id'); + }); + } + + /** + * @param array $authorizedTenants + */ + private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + $tenantIds = array_keys($authorizedTenants); + + return AlertDelivery::query() + ->with('tenant') + ->where('workspace_id', (int) $workspace->getKey()) + ->where('status', AlertDelivery::STATUS_FAILED) + ->where(function ($query) use ($selectedTenant, $tenantIds): void { + if ($selectedTenant instanceof Tenant) { + $query->where('tenant_id', (int) $selectedTenant->getKey()); + + return; + } + + $query + ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) + ->orWhereNull('tenant_id'); + }); + } + + /** + * @return array + */ + private function findingEntry(Finding $finding, string $familyKey, ?CanonicalNavigationContext $navigationContext, int $baseUrgencyRank): array + { + $sublineParts = array_values(array_filter([ + $finding->owner_user_id !== null ? 'Owner: '.FindingResource::accountableOwnerDisplayFor($finding) : null, + FindingExceptionResource::relativeTimeDescription($finding->due_at) ?? FindingResource::dueAttentionLabelFor($finding), + $finding->reopened_at !== null ? 'Reopened' : null, + ])); + + return [ + 'family_key' => $familyKey, + 'source_model' => Finding::class, + 'source_key' => (string) $finding->getKey(), + 'tenant_id' => $finding->tenant ? (int) $finding->tenant->getKey() : null, + 'tenant_label' => $finding->tenant?->name, + 'headline' => $finding->resolvedSubjectDisplayName() ?? 'Finding #'.$finding->getKey(), + 'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts), + 'urgency_rank' => $baseUrgencyRank + + ($finding->due_at?->isPast() === true ? 0 : 1) + + ($finding->reopened_at !== null ? 0 : 1), + 'status_label' => Str::of((string) $finding->status)->replace('_', ' ')->title()->value(), + 'destination_url' => $this->appendQuery( + FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $finding->tenant), + $navigationContext?->toQuery() ?? [], + ), + 'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox', + ]; + } + + /** + * @return array + */ + private function operationEntry(OperationRun $run, ?CanonicalNavigationContext $navigationContext): array + { + $problemClass = $run->problemClass(); + + return [ + 'family_key' => 'stale_operations', + 'source_model' => OperationRun::class, + 'source_key' => (string) $run->getKey(), + 'tenant_id' => $run->tenant ? (int) $run->tenant->getKey() : null, + 'tenant_label' => $run->tenant?->name, + 'headline' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP + ? 'Terminal follow-up operation' + : 'Stale active operation', + 'subline' => OperationRunLinks::identifier($run), + 'urgency_rank' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP ? 0 : 1, + 'status_label' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP + ? 'Terminal follow-up' + : 'Stale', + 'destination_url' => OperationRunLinks::tenantlessView($run, $navigationContext), + 'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox', + ]; + } + + /** + * @return array + */ + private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext $navigationContext): array + { + $payload = is_array($delivery->payload) ? $delivery->payload : []; + $headline = is_string($payload['title'] ?? null) && $payload['title'] !== '' + ? (string) $payload['title'] + : 'Failed alert delivery'; + $sublineParts = array_values(array_filter([ + is_string($delivery->last_error_message) && $delivery->last_error_message !== '' + ? $delivery->last_error_message + : null, + is_string($delivery->event_type) && $delivery->event_type !== '' + ? $delivery->event_type + : null, + ])); + + return [ + 'family_key' => 'alert_delivery_failures', + 'source_model' => AlertDelivery::class, + 'source_key' => (string) $delivery->getKey(), + 'tenant_id' => $delivery->tenant ? (int) $delivery->tenant->getKey() : null, + 'tenant_label' => $delivery->tenant?->name, + 'headline' => $headline, + 'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts), + 'urgency_rank' => 0, + 'status_label' => 'Failed', + 'destination_url' => $this->appendQuery( + AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin'), + $navigationContext?->toQuery() ?? [], + ), + 'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox', + ]; + } + + /** + * @param array $row + * @return array + */ + private function reviewEntry( + Tenant $tenant, + string $family, + array $row, + mixed $latestPublishedReview, + ?CanonicalNavigationContext $navigationContext, + ): array { + $state = (string) ($row['derived_state'] ?? TenantTriageReview::DERIVED_STATE_NOT_REVIEWED); + $familyLabel = $family === PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH + ? 'Backup health' + : 'Recovery evidence'; + $headline = $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED + ? $familyLabel.' needs review follow-up' + : $familyLabel.' changed since review'; + $sublineParts = array_values(array_filter([ + is_string($row['reviewed_by_user_name'] ?? null) && $row['reviewed_by_user_name'] !== '' + ? 'Last review: '.$row['reviewed_by_user_name'] + : null, + isset($row['reviewed_at']) && $row['reviewed_at'] !== null + ? 'Reviewed '.optional($row['reviewed_at'])->toDateTimeString() + : null, + ])); + $destinationUrl = $latestPublishedReview !== null + ? TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview], $tenant, 'tenant') + : CustomerReviewWorkspace::tenantPrefilterUrl($tenant); + + return [ + 'family_key' => 'review_follow_up', + 'source_model' => TenantTriageReview::class, + 'source_key' => (string) $tenant->getKey().':'.$family, + 'tenant_id' => (int) $tenant->getKey(), + 'tenant_label' => $tenant->name, + 'headline' => $headline, + 'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts), + 'urgency_rank' => $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED ? 0 : 1, + 'status_label' => $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED + ? 'Follow-up needed' + : 'Changed since review', + 'destination_url' => $this->appendQuery($destinationUrl, $navigationContext?->toQuery() ?? []), + 'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox', + ]; + } + + private function assignedFindingsSummary(int $count, int $overdueCount): string + { + if ($count === 0) { + return 'No assigned findings are visible in the current scope.'; + } + + if ($overdueCount > 0) { + return sprintf( + '%d assigned finding%s remain open. %d %s overdue.', + $count, + $count === 1 ? '' : 's', + $overdueCount, + $overdueCount === 1 ? 'is' : 'are', + ); + } + + return sprintf( + '%d assigned finding%s remain open in the visible scope.', + $count, + $count === 1 ? '' : 's', + ); + } + + private function intakeFindingsSummary(int $count, int $needsTriageCount): string + { + if ($count === 0) { + return 'No intake findings are visible in the current scope.'; + } + + return sprintf( + '%d unassigned finding%s remain in intake. %d still need first triage.', + $count, + $count === 1 ? '' : 's', + $needsTriageCount, + ); + } + + private function operationsSummary(int $terminalCount, int $staleCount): string + { + if ($terminalCount + $staleCount === 0) { + return 'No stale or terminal follow-up operations are visible in the current scope.'; + } + + if ($terminalCount > 0 && $staleCount > 0) { + return sprintf( + '%d terminal follow-up operation%s and %d stale active run%s need monitoring attention.', + $terminalCount, + $terminalCount === 1 ? '' : 's', + $staleCount, + $staleCount === 1 ? '' : 's', + ); + } + + if ($terminalCount > 0) { + return sprintf( + '%d terminal follow-up operation%s need monitoring attention.', + $terminalCount, + $terminalCount === 1 ? '' : 's', + ); + } + + return sprintf( + '%d stale active run%s need monitoring attention.', + $staleCount, + $staleCount === 1 ? '' : 's', + ); + } + + private function alertsSummary(int $count): string + { + if ($count === 0) { + return 'No failed alert deliveries are visible in the current scope.'; + } + + return sprintf( + '%d failed alert delivery attempt%s remain visible in this workspace.', + $count, + $count === 1 ? '' : 's', + ); + } + + private function reviewSummary(int $followUpCount, int $changedCount): string + { + $total = $followUpCount + $changedCount; + + if ($total === 0) { + return 'No review follow-up is visible in the current scope.'; + } + + return sprintf( + '%d review concern%s need attention. %d marked follow-up needed and %d changed since review.', + $total, + $total === 1 ? '' : 's', + $followUpCount, + $changedCount, + ); + } + + /** + * @param array $query + */ + private function appendQuery(string $url, array $query): string + { + if ($query === []) { + return $url; + } + + $separator = str_contains($url, '?') ? '&' : '?'; + + return $url.$separator.http_build_query($query); + } +} \ No newline at end of file diff --git a/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php b/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php new file mode 100644 index 00000000..6280e61e --- /dev/null +++ b/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php @@ -0,0 +1,164 @@ + + @php + $scope = $this->appliedScope(); + $sections = $this->sections(); + $emptyState = $this->calmEmptyState(); + @endphp + + +
+
+ + Governance inbox +
+ +
+

+ Governance inbox +

+ +

+ This workspace decision surface routes you into the existing findings, operations, alerts, and review surfaces without introducing a second workflow state. +

+
+ +
+ @if (filled($scope['workspace_label'] ?? null)) + + Workspace: {{ $scope['workspace_label'] }} + + @endif + + + Scope: {{ $scope['family_label'] ?? 'All attention' }} + + + + Visible items: {{ $scope['total_count'] ?? 0 }} + + + @if (filled($scope['tenant_label'] ?? null)) + + Tenant: {{ $scope['tenant_label'] }} + + @endif +
+ +
+ + All attention + {{ $scope['total_count'] ?? 0 }} + + + @foreach ($this->availableFamilies() as $family) + + {{ $family['label'] }} + {{ $family['count'] }} + + @endforeach +
+ + @if ($this->hasTenantPrefilter()) +
+ The inbox is currently filtered to one tenant. + + + Clear tenant filter + +
+ @endif +
+
+ + @if ($sections === []) + +
+
+

{{ $emptyState['title'] }}

+

{{ $emptyState['body'] }}

+
+ + @if (filled($emptyState['action_label'] ?? null) && filled($emptyState['action_url'] ?? null)) +
+ + {{ $emptyState['action_label'] }} + +
+ @endif +
+
+ @else + @foreach ($sections as $section) + +
+
+
+
+

{{ $section['label'] }}

+ + {{ $section['count'] }} + +
+ +

{{ $section['summary'] }}

+
+ +
+ + {{ $section['dominant_action_label'] }} + +
+
+ + @if ($section['count'] === 0) +
+ {{ $section['empty_state'] }} +
+ @else +
    + @foreach ($section['entries'] as $entry) +
  • +
    +
    + @if (filled($entry['tenant_label'] ?? null)) +
    + {{ $entry['tenant_label'] }} +
    + @endif + +
    + + {{ $entry['headline'] }} + + + + {{ $entry['status_label'] }} + +
    + + @if (filled($entry['subline'] ?? null)) +

    {{ $entry['subline'] }}

    + @endif +
    + +
    + + Open source + +
    +
    +
  • + @endforeach +
+ @endif +
+
+ @endforeach + @endif +
\ No newline at end of file diff --git a/apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php b/apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php new file mode 100644 index 00000000..2b83361f --- /dev/null +++ b/apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php @@ -0,0 +1,99 @@ +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(GovernanceInbox::getUrl(panel: 'admin')) + ->assertRedirect('/admin/choose-workspace'); +}); + +it('returns 404 for users outside the active workspace on the governance inbox 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(GovernanceInbox::getUrl(panel: 'admin')) + ->assertNotFound(); +}); + +it('returns 403 for workspace members with no qualifying family visibility anywhere', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + mock(WorkspaceCapabilityResolver::class, function ($mock): void { + $mock->shouldReceive('isMember')->andReturnTrue(); + $mock->shouldReceive('can')->andReturnFalse(); + }); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(GovernanceInbox::getUrl(panel: 'admin')) + ->assertForbidden(); +}); + +it('allows readonly tenant members to open the governance inbox through operations-family visibility', function (): void { + $tenant = Tenant::factory()->create(['status' => 'active']); + [$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin')) + ->assertOk() + ->assertSee('Governance inbox'); +}); + +it('returns 404 for explicit tenant filters outside the actor scope', function (): void { + $visibleTenant = Tenant::factory()->create(['status' => 'active']); + [$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly'); + + $hiddenTenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $visibleTenant->workspace_id, + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $hiddenTenant->getKey()) + ->assertNotFound(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php b/apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php new file mode 100644 index 00000000..506e626e --- /dev/null +++ b/apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php @@ -0,0 +1,64 @@ +create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner'); + + $finding = Finding::factory() + ->for($tenant) + ->assignedTo((int) $user->getKey()) + ->create(); + + $run = OperationRun::factory() + ->forTenant($tenant) + ->create([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'completed_at' => now()->subMinute(), + ]); + + $context = new CanonicalNavigationContext( + sourceSurface: 'governance.inbox', + canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')), + backLinkLabel: 'Back to governance inbox', + backLinkUrl: GovernanceInbox::getUrl(panel: 'admin'), + ); + + $response = $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin')); + + $response->assertOk(); + + $expectedMyFindingsUrl = htmlspecialchars( + MyFindingsInbox::getUrl(panel: 'admin').'?'.http_build_query($context->toQuery()), + ENT_QUOTES, + ); + $expectedOperationUrl = htmlspecialchars( + OperationRunLinks::tenantlessView($run, $context), + ENT_QUOTES, + ); + + $response->assertSee($expectedMyFindingsUrl, false) + ->assertSee($expectedOperationUrl, false) + ->assertSee((string) $finding->getKey()) + ->assertSee('nav%5Bback_label%5D=Back+to+governance+inbox', false); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php b/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php new file mode 100644 index 00000000..4f3a33a0 --- /dev/null +++ b/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php @@ -0,0 +1,143 @@ +create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $alphaTenant] = createUserWithTenant($alphaTenant, role: 'owner', workspaceRole: 'owner'); + + $bravoTenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'name' => 'Bravo Tenant', + 'external_id' => 'bravo-tenant', + ]); + + $user->tenants()->syncWithoutDetaching([ + (int) $bravoTenant->getKey() => ['role' => 'owner'], + ]); + + Finding::factory() + ->for($alphaTenant) + ->assignedTo((int) $user->getKey()) + ->ownedBy((int) $user->getKey()) + ->overdueByHours() + ->create(); + + Finding::factory() + ->for($bravoTenant) + ->reopened() + ->create(); + + OperationRun::factory() + ->forTenant($alphaTenant) + ->create([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'completed_at' => now()->subMinute(), + ]); + + AlertDelivery::factory()->create([ + 'tenant_id' => null, + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'status' => AlertDelivery::STATUS_FAILED, + 'payload' => [ + 'title' => 'Delivery failed', + 'body' => 'A notification destination failed.', + ], + ]); + + $backupHealthResolver = app(TenantBackupHealthResolver::class); + $fingerprints = app(TenantTriageReviewFingerprint::class); + $alphaBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($alphaTenant)); + + expect($alphaBackupFingerprint)->not->toBeNull(); + + TenantTriageReview::factory() + ->for($alphaTenant) + ->followUpNeeded() + ->create([ + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'reviewed_by_user_id' => (int) $user->getKey(), + 'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, + 'review_fingerprint' => $alphaBackupFingerprint['fingerprint'], + 'review_snapshot' => $alphaBackupFingerprint['snapshot'], + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin')) + ->assertOk() + ->assertSee('Assigned findings') + ->assertSee('Findings intake') + ->assertSee('Operations follow-up') + ->assertSee('Alert delivery failures') + ->assertSee('Review follow-up') + ->assertSee('Open my findings') + ->assertSee('Open terminal follow-up') + ->assertSee('Open alert deliveries') + ->assertSee('Open review follow-up'); +}); + +it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void { + $alphaTenant = Tenant::factory()->create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $alphaTenant] = createUserWithTenant($alphaTenant, role: 'owner', workspaceRole: 'owner'); + + $bravoTenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'name' => 'Bravo Tenant', + 'external_id' => 'bravo-tenant', + ]); + + $user->tenants()->syncWithoutDetaching([ + (int) $bravoTenant->getKey() => ['role' => 'owner'], + ]); + + Finding::factory() + ->for($bravoTenant) + ->assignedTo((int) $user->getKey()) + ->create(); + + AlertDelivery::factory()->create([ + 'tenant_id' => null, + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'status' => AlertDelivery::STATUS_FAILED, + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $alphaTenant->getKey()) + ->assertOk() + ->assertSee('This tenant filter is hiding other visible attention') + ->assertSee('Clear tenant filter'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $alphaTenant->getKey().'&family=alert_delivery_failures') + ->assertOk() + ->assertSee('Alert delivery failures') + ->assertSee('No failed alert deliveries match this tenant filter right now.') + ->assertDontSee('Open my findings'); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/PlatformRelocation/CommandModelSmokeTest.php b/apps/platform/tests/Feature/PlatformRelocation/CommandModelSmokeTest.php index e39b2941..f9fed5b6 100644 --- a/apps/platform/tests/Feature/PlatformRelocation/CommandModelSmokeTest.php +++ b/apps/platform/tests/Feature/PlatformRelocation/CommandModelSmokeTest.php @@ -27,3 +27,10 @@ ->toContain(".:/var/www/repo:ro") ->toContain('TENANTATLAS_REPO_ROOT: /var/www/repo'); }); + +it('keeps the local queue service in code-reloading listen mode', function (): void { + $compose = file_get_contents(repo_path('docker-compose.yml')); + + expect($compose)->toContain('command: php artisan queue:listen --tries=3 --timeout=300 --sleep=3') + ->not->toContain('command: php artisan queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000'); +}); diff --git a/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php b/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php new file mode 100644 index 00000000..938561ac --- /dev/null +++ b/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php @@ -0,0 +1,197 @@ +create(); + $user = User::factory()->create(); + + $alphaTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + $bravoTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Bravo Tenant', + 'external_id' => 'bravo-tenant', + ]); + + Finding::factory() + ->for($alphaTenant) + ->assignedTo((int) $user->getKey()) + ->ownedBy((int) $user->getKey()) + ->overdueByHours() + ->create([ + 'status' => Finding::STATUS_IN_PROGRESS, + 'subject_external_id' => 'assigned-finding', + ]); + + Finding::factory() + ->for($bravoTenant) + ->reopened() + ->create([ + 'subject_external_id' => 'intake-finding', + ]); + + OperationRun::factory() + ->forTenant($alphaTenant) + ->create([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'completed_at' => now()->subMinute(), + ]); + + OperationRun::factory() + ->forTenant($bravoTenant) + ->create([ + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(6), + ]); + + AlertDelivery::factory()->create([ + 'tenant_id' => null, + 'workspace_id' => (int) $workspace->getKey(), + 'status' => AlertDelivery::STATUS_FAILED, + 'event_type' => 'alerts.failed_delivery', + 'payload' => [ + 'title' => 'Delivery failed', + 'body' => 'Alert delivery could not be completed.', + ], + ]); + + $backupHealthResolver = app(TenantBackupHealthResolver::class); + $fingerprints = app(TenantTriageReviewFingerprint::class); + + $alphaBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($alphaTenant)); + $bravoBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($bravoTenant)); + + expect($alphaBackupFingerprint)->not->toBeNull() + ->and($bravoBackupFingerprint)->not->toBeNull(); + + TenantTriageReview::factory() + ->for($alphaTenant) + ->followUpNeeded() + ->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'reviewed_by_user_id' => (int) $user->getKey(), + 'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, + 'review_fingerprint' => $alphaBackupFingerprint['fingerprint'], + 'review_snapshot' => $alphaBackupFingerprint['snapshot'], + 'reviewed_at' => now()->subDay(), + ]); + + TenantTriageReview::factory() + ->for($bravoTenant) + ->reviewed() + ->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'reviewed_by_user_id' => (int) $user->getKey(), + 'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, + 'review_fingerprint' => hash('sha256', 'stale-review-fingerprint'), + 'review_snapshot' => $bravoBackupFingerprint['snapshot'], + 'reviewed_at' => now()->subDays(2), + ]); + + $context = new CanonicalNavigationContext( + sourceSurface: 'governance.inbox', + canonicalRouteName: 'filament.admin.pages.governance.inbox', + backLinkLabel: 'Back to governance inbox', + backLinkUrl: '/admin/governance/inbox', + ); + + $payload = app(GovernanceInboxSectionBuilder::class)->build( + user: $user, + workspace: $workspace, + authorizedTenants: [$alphaTenant, $bravoTenant], + visibleFindingTenants: [$alphaTenant, $bravoTenant], + reviewTenants: [$alphaTenant, $bravoTenant], + canViewAlerts: true, + navigationContext: $context, + ); + + expect(collect($payload['sections'])->pluck('key')->all()) + ->toBe([ + 'assigned_findings', + 'intake_findings', + 'stale_operations', + 'alert_delivery_failures', + 'review_follow_up', + ]) + ->and($payload['family_counts'])->toMatchArray([ + 'assigned_findings' => 1, + 'intake_findings' => 1, + 'stale_operations' => 2, + 'alert_delivery_failures' => 1, + 'review_follow_up' => 2, + ]); + + $sections = collect($payload['sections'])->keyBy('key'); + + expect($sections['assigned_findings']['dominant_action_url']) + ->toContain('/admin/findings/my-work') + ->toContain('nav%5Bback_label%5D=Back+to+governance+inbox') + ->and($sections['stale_operations']['dominant_action_label'])->toBe('Open terminal follow-up') + ->and($sections['stale_operations']['dominant_action_url'])->toContain('problemClass=terminal_follow_up') + ->and($sections['alert_delivery_failures']['dominant_action_url'])->toContain('tableFilters%5Bstatus%5D%5Bvalue%5D=failed') + ->and(collect($sections['review_follow_up']['entries'])->pluck('status_label')->all()) + ->toBe(['Follow-up needed', 'Changed since review']); +}); + +it('keeps an explicitly selected visible family with an honest empty state when tenant filtering removes every row', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + $alphaTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + $bravoTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Bravo Tenant', + 'external_id' => 'bravo-tenant', + ]); + + AlertDelivery::factory()->create([ + 'tenant_id' => null, + 'workspace_id' => (int) $workspace->getKey(), + 'status' => AlertDelivery::STATUS_FAILED, + ]); + + $payload = app(GovernanceInboxSectionBuilder::class)->build( + user: $user, + workspace: $workspace, + authorizedTenants: [$alphaTenant, $bravoTenant], + visibleFindingTenants: [], + reviewTenants: [], + canViewAlerts: true, + selectedTenant: $alphaTenant, + selectedFamily: 'alert_delivery_failures', + ); + + expect($payload['sections'])->toHaveCount(1) + ->and($payload['sections'][0]['key'])->toBe('alert_delivery_failures') + ->and($payload['sections'][0]['count'])->toBe(0) + ->and($payload['sections'][0]['empty_state'])->toContain('tenant filter'); +}); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 20e950b0..cd60f661 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,7 +62,7 @@ services: - laravel.test - pgsql - redis - command: php artisan queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000 + command: php artisan queue:listen --tries=3 --timeout=300 --sleep=3 pgsql: image: 'postgres:16' diff --git a/specs/250-decision-governance-inbox/checklists/requirements.md b/specs/250-decision-governance-inbox/checklists/requirements.md new file mode 100644 index 00000000..936e80cf --- /dev/null +++ b/specs/250-decision-governance-inbox/checklists/requirements.md @@ -0,0 +1,70 @@ +# Preparation Review Checklist: Decision-Based Governance Inbox v1 + +**Purpose**: Validate the governance inbox preparation package against the repo's guardrail, disclosure, shared-family, and close-out workflow before implementation +**Created**: 2026-04-28 +**Feature**: [spec.md](../spec.md) + +## Applicability And Low-Impact Gate + +- [x] CHK001 The package explicitly treats this as an operator-facing workspace decision surface, so the low-impact `N/A` path is not used. +- [x] CHK002 The spec, plan, and tasks carry the same native/shared-primitives-first classification, shared-family relevance, state ownership, and close-out targeting without inventing second wording. + +## Native, Shared-Family, And State Ownership + +- [x] CHK003 The inbox remains a native Filament page that reuses existing source surfaces instead of introducing a fake-native task console or separate monitoring shell. +- [x] CHK004 Shared families remain shared: findings, operations, alerts, and review follow-up stay on their existing source pages, while the new page stays a routing and decision layer. +- [x] CHK005 Page and URL-query state owners are named once, and the package does not collapse them into new persisted workflow state. +- [x] CHK006 The likely next operator action and primary inspect/open model stay coherent: each section has one dominant source CTA and the page owns no mutation lane. + +## Shared Pattern Reuse + +- [x] CHK007 Cross-cutting interaction classes are explicit, and the shared reuse path is named once through `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, and the existing source pages. +- [x] CHK008 The package extends existing shared paths where they are sufficient, and any fallback to a bounded `Support/GovernanceInbox/` seam is explicitly constrained as a last resort rather than a new default abstraction. +- [x] CHK009 The package does not create a parallel operator UX language for claim, acknowledge, stale-run handling, or review follow-up; it routes into the current source-family vocabulary. + +## OperationRun Start UX Contract + +- [x] CHK019 The package explicitly states that the inbox only deep-links into existing `OperationRun` detail and does not start, queue, or complete runs. +- [x] CHK020 Canonical operation URLs are delegated to the shared `OperationRunLinks` path rather than recomposed locally on the inbox page. +- [x] CHK021 No queued DB-notification or terminal-notification behavior is added because the slice is read-only. +- [x] CHK022 No OperationRun exception is required; if implementation later adds local run-start or blocked-run messaging, that would be out-of-scope drift. + +## Provider Boundary And Vocabulary + +- [x] CHK010 The package keeps provider-specific semantics behind existing normalized governance, alerting, and review seams and does not spread provider language into a new platform-core contract. +- [x] CHK011 No retained provider-specific shared boundary is introduced; the slice stays inside existing workspace, tenant, operations, findings, alerts, and review vocabulary. + +## Signals, Exceptions, And Test Depth + +- [x] CHK012 The triggered repository signal is explicitly handled as `review-mandatory`, with no hidden hard-stop drift accepted into the package. +- [x] CHK013 No bounded exception is required in the preparation package; if implementation proves a bounded assembly helper is necessary, it must be recorded in the active feature close-out entry. +- [x] CHK014 The required surface test profile is explicit: `global-context-shell`. +- [x] CHK015 The chosen lane mix is the narrowest honest proof for this slice: focused `Unit` plus `Feature` coverage only. + +## Audience-Aware Disclosure And Decision Hierarchy + +- [x] CHK023 Default-visible content stays decision-first and clearly separated from deeper diagnostics and support or raw evidence. +- [x] CHK024 The inbox default path does not expose raw JSON, copied payloads, provider diagnostics, or other debug semantics by default. +- [x] CHK025 Exactly one dominant next action remains primary per section or entry: open the relevant existing source surface. +- [x] CHK026 Duplicate visible blocker, status, or next-action summaries are avoided by keeping proof and detailed reasoning on the source pages. +- [x] CHK027 Support/raw sections remain off the inbox page entirely, and the page stays within Filament visual language, progressive disclosure, and calm read-only presentation. + +## Review Outcome + +- [x] CHK016 Review outcome class: `acceptable-special-case` +- [x] CHK017 Workflow outcome: `keep` +- [x] CHK018 The final note location is explicit: the active feature PR close-out entry `Guardrail / Exception / Smoke Coverage` records any bounded assembly-seam exception and the final proof outcome. + +## Notes + +- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and supporting design artifacts. It does not claim application code exists. +- The slice remains bounded to one read-only workspace decision surface in the current admin plane. No new task engine, no new attention state, and no local mutation lane are approved by this package. +- If implementation later proves that a bounded `Support/GovernanceInbox/` seam is necessary, that must stay derived and page-scoped rather than becoming a generalized workflow framework. + +## Guardrail / Exception / Smoke Coverage + +- Implementation status: complete for the bounded v1 slice. +- Guardrail result: PASS. The implemented page stayed native, read-only, shared-primitives-first, and inside the existing admin plane without adding a new task engine, persisted inbox truth, or local mutation lane. +- Bounded exception result: `document-in-feature`. `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` was added as the smallest readable cross-family assembly seam. +- Validation result: the focused unit and feature proof command passed with `10 passed (53 assertions)`, and dirty-only Pint passed. +- Smoke result: PASS. A manual integrated-browser run on `/admin/governance/inbox` verified route load, canonical operations drill-through with `nav` context, and successful return to the inbox. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml b/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml new file mode 100644 index 00000000..89ce5f8d --- /dev/null +++ b/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml @@ -0,0 +1,159 @@ +openapi: 3.1.0 +info: + title: Decision-Based Governance Inbox v1 + version: 0.1.0 + summary: Conceptual contract for the canonical governance inbox page. +paths: + /admin/governance/inbox: + get: + summary: Render the governance inbox page + description: >- + Returns the derived governance inbox composition for the current workspace actor. + This is a conceptual page contract used for planning, not a public API commitment. + parameters: + - in: query + name: tenant_id + schema: + type: integer + nullable: true + description: Optional tenant prefilter. Out-of-scope values resolve as not found. + - in: query + name: family + schema: + type: string + enum: + - assigned_findings + - intake_findings + - stale_operations + - alert_delivery_failures + - review_follow_up + description: Optional source-family filter. `stale_operations` is the canonical key for both stale and terminal-follow-up operations attention. + - in: query + name: nav[source_surface] + schema: + type: string + description: Optional shared navigation context source. + responses: + '200': + description: Derived governance inbox payload for page rendering. + content: + application/json: + schema: + type: object + required: + - title + - applied_scope + - sections + properties: + title: + type: string + example: Governance inbox + applied_scope: + type: object + properties: + tenant_id: + type: integer + nullable: true + family: + type: string + nullable: true + workspace_scoped: + type: boolean + sections: + type: array + items: + type: object + required: + - key + - label + - count + - summary + - dominant_action + - entries + properties: + key: + type: string + description: Family key; `stale_operations` covers stale and terminal-follow-up operations attention. + label: + type: string + count: + type: integer + summary: + type: string + empty_state: + type: string + nullable: true + description: Family-specific empty-state copy used when the family is explicitly selected but has no visible entries. + dominant_action: + type: object + required: + - label + - url + properties: + label: + type: string + url: + type: string + entries: + type: array + items: + type: object + required: + - family_key + - source_model + - source_key + - headline + - status_label + - destination_url + properties: + family_key: + type: string + description: Matches the owning section key; `stale_operations` covers stale and terminal-follow-up operations attention. + source_model: + type: string + source_key: + type: string + tenant_id: + type: integer + nullable: true + tenant_label: + type: string + nullable: true + headline: + type: string + subline: + type: string + nullable: true + urgency_rank: + type: integer + status_label: + type: string + destination_url: + type: string + back_label: + type: string + nullable: true + '404': + description: Workspace membership missing or explicit tenant prefilter is outside scope. + content: + application/json: + schema: + type: object + required: + - message + properties: + message: + type: string + example: Not Found + '403': + description: Workspace member is in scope but lacks every qualifying visible-family capability for the inbox. + content: + application/json: + schema: + type: object + required: + - message + properties: + message: + type: string + example: Forbidden \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/data-model.md b/specs/250-decision-governance-inbox/data-model.md new file mode 100644 index 00000000..cdadd947 --- /dev/null +++ b/specs/250-decision-governance-inbox/data-model.md @@ -0,0 +1,103 @@ +# Data Model: Decision-Based Governance Inbox v1 + +**Date**: 2026-04-28 +**Feature**: [spec.md](spec.md) + +## Model Posture + +This slice introduces no new persisted entity. Every object below is a derived read model used to compose one decision-first page over existing repo truth. + +## Existing Source Truth + +| Source Model | Ownership | Relevant Truth Reused | +|---|---|---| +| `Finding` | tenant-owned | assigned work, intake work, severity, due or overdue state, reopened state, tenant entitlement | +| `OperationRun` | tenant-owned with workspace monitoring access | stale or terminal-follow-up attention, canonical run destination | +| `AlertDelivery` | workspace-scoped | failed or otherwise operator-relevant alert delivery outcomes | +| `TenantReview` | tenant-owned | latest review drill-through destination | +| `TenantTriageReview` | tenant-owned | follow-up-needed and changed-since-review attention | + +## Derived Read Models + +### GovernanceInboxSection + +Represents one visible source family on the inbox page. + +| Field | Type | Notes | +|---|---|---| +| `key` | string | bounded page-local family key such as `assigned_findings`, `intake_findings`, `stale_operations`, `alert_delivery_failures`, `review_follow_up`; `stale_operations` is the canonical key for both stale and terminal-follow-up operations attention | +| `label` | string | operator-facing section title aligned to the source family | +| `count` | int | visible item count for the current actor and active filters | +| `summary` | string | calm one-line summary of why the family matters | +| `dominant_action_label` | string | primary CTA label, routed to the existing source surface | +| `dominant_action_url` | string | canonical source destination | +| `entries` | list | bounded preview list, not a second queue truth | +| `empty_state` | string | optional local empty explanation when the family is selected explicitly | + +### GovernanceAttentionEntry + +Represents one preview item inside a visible section. + +| Field | Type | Notes | +|---|---|---| +| `family_key` | string | matches the owning `GovernanceInboxSection.key` | +| `source_model` | string | `Finding`, `OperationRun`, `AlertDelivery`, `TenantReview`, or `TenantTriageReview` | +| `source_key` | string | stable source identifier for routing only | +| `tenant_id` | int or null | nullable for workspace-scoped alert or run cases | +| `tenant_label` | string or null | only shown when truthful | +| `headline` | string | concise operator-facing summary | +| `subline` | string or null | bounded reason, owner, or due-state context | +| `urgency_rank` | int | derived sort priority within the family | +| `status_label` | string | reused source-family wording | +| `destination_url` | string | existing canonical route | +| `back_label` | string | return label back to the inbox | + +## Filter State + +### GovernanceInboxFilterState + +| Field | Type | Notes | +|---|---|---| +| `tenant_id` | int or null | optional tenant prefilter; explicit out-of-scope values return `404` | +| `family` | string or null | optional family filter for one visible source family; `stale_operations` remains the canonical filter key for stale or terminal-follow-up operations attention | +| `nav` | array or null | optional shared navigation payload used for return continuity | + +## Ordering Rules + +### Section Order + +1. Assigned findings +2. Findings intake +3. Stale or terminal-follow-up operations +4. Alert-delivery failures +5. Review follow-up + +This order is deliberately explicit and page-local. It is not a new persisted workflow taxonomy. + +### Entry Order + +- Findings-based sections reuse their existing queue ordering. +- Operations reuse the current monitoring-attention ordering exposed by the canonical operations surface. +- Alert-delivery failures order newest unresolved operator-relevant failures first. +- Review follow-up orders explicit follow-up-needed states before changed-since-review states. + +## Relationships + +- One `GovernanceInboxSection` maps to one existing source family. +- One `GovernanceInboxSection` has many derived `GovernanceAttentionEntry` values. +- Each `GovernanceAttentionEntry` points to exactly one existing source record and one existing source destination. +- No derived object owns or mutates source truth. + +## Persistence Rules + +- No new table. +- No new cache. +- No new inbox-specific audit stream. +- No new acknowledged, snoozed, or assigned state. + +## Data Integrity Rules + +- Hidden tenants never contribute to derived section counts or entry previews. +- Family visibility is capability-driven; invisible families do not render empty placeholders. +- Tenantless alert or operation entries must not invent tenant labels. +- Source destinations must stay canonical and existing; the inbox must not invent a parallel detail shell. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/plan.md b/specs/250-decision-governance-inbox/plan.md new file mode 100644 index 00000000..e8576d4e --- /dev/null +++ b/specs/250-decision-governance-inbox/plan.md @@ -0,0 +1,305 @@ +# Implementation Plan: Decision-Based Governance Inbox v1 + +**Branch**: `250-decision-governance-inbox` | **Date**: 2026-04-28 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from [spec.md](spec.md) + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Introduce one canonical workspace governance inbox inside the existing `/admin` plane by adding a native Filament v5 read-only page that composes existing findings, alerts, stale-operations, and portfolio-triage signals into one decision-first work surface. The page should answer the first operator question quickly, then route into the existing source pages for execution and proof instead of creating a new cross-domain task engine. + +This slice is explicitly composition-only. It does not replace `My Findings`, `Findings intake`, `Operations`, `Alerts`, or review-triage detail surfaces; it does not add acknowledge, snooze, claim, or assignment mutations; and it does not create persistence. Livewire remains v4 under Filament v5, panel-provider registration stays in `apps/platform/bootstrap/providers.php`, no new globally searchable resource is introduced, and no new asset bundle is expected for v1. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing findings, alerts, operations, and review-triage services +**Storage**: PostgreSQL via existing `findings`, `operation_runs`, `alert_deliveries`, `tenant_reviews`, and `tenant_triage_reviews`; no new persistence planned +**Testing**: Pest v4 unit plus feature coverage +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Laravel monolith in `apps/platform` running via Sail, with existing `/admin` and tenant-scoped `/admin/t/{tenant}` surfaces +**Project Type**: Web application (Laravel monolith with Filament panels) +**Performance Goals**: page render remains DB-only and workspace-scoped; no Graph calls, no queue starts, and no remote work on render; family previews should be fetched through bounded derived queries rather than one polymorphic persistence layer +**Constraints**: preserve deny-as-not-found workspace and tenant isolation; keep the first slice in the existing admin plane; avoid new persistence, new workflow states, new task engines, and page-local mutation semantics; reuse source-page routing and action hierarchies +**Scale/Scope**: 1 new admin page, 5 derived source families, 0 new runtime entities, and 1 bounded derived section assembly seam + +## Likely Affected Repo Surfaces + +- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` for assigned-findings truth, urgency ordering, and workspace-shell tenant-prefilter behavior. +- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` for intake truth, `Needs triage` semantics, and read-first queue behavior. +- `apps/platform/app/Filament/Pages/Monitoring/Operations.php` plus `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` for stale or terminal-follow-up operation attention and canonical run drill-through. +- `apps/platform/app/Filament/Pages/Monitoring/Alerts.php`, `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`, and the existing alerts cluster for alert-family entry points and delivery-failure truth. +- `apps/platform/app/Filament/Resources/TenantReviewResource.php`, `apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php`, and `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php` for review follow-up and triage-state truth. +- `apps/platform/app/Models/Finding.php`, `apps/platform/app/Models/OperationRun.php`, `apps/platform/app/Models/AlertDelivery.php`, `apps/platform/app/Models/TenantReview.php`, and `apps/platform/app/Models/TenantTriageReview.php` for the source data contracts. +- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and `apps/platform/app/Support/OperationRunLinks.php` for source-page routing and return-link continuity. +- `apps/platform/app/Support/OperateHub/OperateHubShell.php`, `apps/platform/app/Support/Filament/CanonicalAdminTenantFilterState.php`, and `apps/platform/app/Support/Filament/TablePaginationProfiles.php` for workspace scope and durable filter state. +- `apps/platform/app/Support/Badges/BadgeRenderer.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceDeclaration.php`, `apps/platform/app/Support/Rbac/UiEnforcement.php`, and `apps/platform/app/Support/Rbac/UiTooltips.php` for existing status, action, and capability affordance patterns. +- Likely new implementation files if code work later proceeds: `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`, and a bounded support namespace under `apps/platform/app/Support/GovernanceInbox/` only if the page cannot stay readable with page-local composition. + +## UI / Filament & Livewire Fit + +- Implement as a native Filament v5 `Page` in the existing admin plane, not as a new Resource, custom SPA shell, or second monitoring console. +- Keep the inbox read-first and section-based. Each visible family should render one calm summary block plus bounded preview entries and one dominant CTA into the existing source surface. +- Do not model the inbox as a polymorphic table over mixed Eloquent records if that forces a new persisted or generic task abstraction. Section composition over existing family queries is the preferred v1 shape. +- Livewire v4 hydration must preserve tenant and family filter state through public, query-backed, or session-backed state. Do not rely on private properties for any state that must survive a Livewire interaction. +- The new surface is a `Page`, not a globally searchable `Resource`. Existing source resources retain their current search posture. + +## RBAC / Policy Fit + +- Workspace membership remains the first gate. The inbox should not render at all for non-members, and explicit out-of-scope tenant targeting must stay `404`. +- Page access stays capability-derived: the actor must be a workspace member and have visibility to at least one family through the same capability contract the source page already uses. In-scope workspace members who lack every qualifying family capability should receive `403`, not a silent empty shell. +- Findings families reuse tenant capability checks such as `Capabilities::TENANT_FINDINGS_VIEW`, while source mutations like claim or triage continue to enforce `Capabilities::TENANT_FINDINGS_ASSIGN` or `Capabilities::TENANT_FINDINGS_TRIAGE` on their existing surfaces. +- Review follow-up entries reuse `Capabilities::TENANT_REVIEW_VIEW`; any manual follow-up mutation remains on the existing review/triage seam and continues to require `Capabilities::TENANT_TRIAGE_REVIEW_MANAGE`. +- Alert-family visibility remains workspace-scoped through `Capabilities::ALERTS_VIEW`. +- Operations entries must only appear when the underlying run destination would already be visible through the existing operation-viewer and tenant-entitlement rules. The inbox must not invent a weaker path. + +## Audit / Logging Fit + +- The inbox is read-only and should not create a new page-view audit stream. +- Existing mutation or download actions continue to log on their existing source surfaces. +- The only acceptable additional audit work in v1 would be reuse of existing action IDs on underlying source pages if implementation discovers a missing drill-through event, but the inbox itself should not become a new audit-heavy surface. + +## Data & Query Fit + +- Prefer derived section queries over a generic inbox-item projector or persisted cache. +- The findings sections should reuse the same inclusion and urgency rules already owned by `MyFindingsInbox` and `FindingsIntakeQueue` rather than duplicating lifecycle logic with new constants. +- The operations section should reuse the same stale or terminal-follow-up classification that already drives the canonical Operations page. Section-level operations CTAs may land on `/admin/operations`, but entry-level operation drill-through should land on the canonical run detail route `/admin/operations/{run}`. +- The alert section should derive from alert-delivery failure truth and the alerts overview, not from alert-rule configuration state. +- The review-follow-up section should derive from `TenantTriageReview` state and existing review register truth, not from a new parallel follow-up model. +- If implementation needs one bounded derived assembly seam, it should remain a page-scoped support helper that normalizes sections and preview entries only. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament +- **Shared-family relevance**: governance queues, monitoring drill-through, navigation continuity, badge/status reuse +- **State layers in scope**: page, URL-query +- **Audience modes in scope**: operator-MSP +- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third on source pages only +- **Raw/support gating plan**: hidden by default on the inbox page; source pages keep their existing capability-gated disclosure +- **One-primary-action / duplicate-truth control**: each section gets one dominant CTA into an existing source surface; later detail stays off the inbox page +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory now; future hard-stop candidate if implementation introduces a generic task model or local mutations +- **Special surface test profiles**: global-context-shell +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none planned; any new cross-domain workflow state or local mutation must be treated as exception-required drift +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `MyFindingsInbox`, `FindingsIntakeQueue`, `Operations`, `Alerts`, `AlertDeliveryResource`, `TenantReviewResource`, `TenantTriageReviewService`, `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, and existing source-page action-surface declarations +- **Shared abstractions reused**: `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, `ActionSurfaceDeclaration`, and current source-page query rules +- **New abstraction introduced? why?**: one bounded section or entry assembler may be needed to keep the page readable and deterministic across families, but it must remain derived and page-scoped +- **Why the existing abstraction was sufficient or insufficient**: existing source pages are sufficient for truth and mutation, but insufficient as the first workspace attention surface because they only answer one family each +- **Bounded deviation / spread control**: none planned. If a support namespace is added, it must stay under `Support/GovernanceInbox/`, remain read-only, and not become a cross-product task engine + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes, deep-link only +- **Central contract reused**: `OperationRunLinks` and the existing tenantless operation viewer +- **Delegated UX behaviors**: existing canonical run URL resolution and navigation context only +- **Surface-owned behavior kept local**: deciding whether an operation attention entry appears and which existing run destination is primary +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: `N/A` +- **Platform-core seams**: existing governance, alerts, operations, and review vocabulary only +- **Neutral platform terms / contracts preserved**: `governance inbox`, `attention`, `operation`, `review follow-up`, `alert delivery failure`, and existing source nouns +- **Retained provider-specific semantics and why**: none +- **Bounded extraction or follow-up path**: `N/A` + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first / snapshot truth: PASS. The inbox consumes existing findings, operations, alerts, and review state only. +- Read/write separation: PASS. The page stays read-only and pushes execution back to source surfaces. +- Graph contract path: PASS. No new Graph calls or provider contract work is part of this slice. +- Deterministic capabilities: PASS. The plan reuses existing capability registries and source-page rules. +- Workspace isolation + tenant isolation: PASS. Workspace membership remains a `404` boundary; explicit out-of-scope tenant filters remain `404`; broad listings omit hidden rows. +- RBAC-UX plane separation: PASS. Everything stays inside the admin `/admin` plane. +- Destructive confirmation standard: PASS by non-use. The inbox introduces no destructive or risky action. +- Global search safety: PASS. The new slice is a Page, not a searchable Resource. +- OperationRun and Ops-UX: PASS by deep-link-only reuse. The page starts no run and adds no new run UX state. +- Data minimization: PASS. Default-visible content stays limited to family, urgency, scope, and next action. +- Test governance (TEST-GOV-001): PASS. Planned proof stays in focused `Unit` and `Feature` lanes only. +- Proportionality / no premature abstraction: PASS with one bounded exception. If a section assembler is needed, it remains page-scoped and derived. +- Persisted truth (PERSIST-001): PASS. No new table, cache, or stored attention entity is planned. +- Behavioral state (STATE-001): PASS. The inbox reuses existing source states and does not add a second workflow state family. +- Shared pattern first / UI semantics / Filament native UI: PASS. Existing navigation, badge, and queue semantics are reused. +- Provider boundary (PROV-001): PASS. The slice stays on already-normalized platform seams. +- Filament / Laravel planning contract: PASS. Filament v5 remains on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, and no new panel is required. +- Asset strategy: PASS. No new asset registration is planned; if implementation later registers an asset anyway, deployment keeps the normal `cd apps/platform && php artisan filament:assets` step. + +**Gate evaluation**: PASS. + +- The inbox stays inside the existing admin plane and current workspace or tenant membership model. +- The page remains a read-only decision hub, not a new execution workflow. +- Existing source pages and services are sufficient for v1 if implementation resists introducing a generic inbox state model. + +**Post-design re-check**: PASS (design artifacts: [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), [contracts/governance-inbox.openapi.yaml](contracts/governance-inbox.openapi.yaml)). + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for section and preview assembly plus source-link decisions; Feature for page rendering, authorization, filter behavior, and navigation continuity +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: unit coverage proves family assembly without Filament boot cost; feature coverage proves page access, family visibility, tenant-prefilter behavior, and source-page routing on a native page +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` +- **Fixture / helper / factory / seed / context cost risks**: moderate; reuse findings, operation runs, alert deliveries, reviews, and triage-review fixtures rather than adding browser setup or generic workflow helpers +- **Expensive defaults or shared helper growth introduced?**: no; any section assembler must stay cheap by default and avoid eager-loading broad unrelated state +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: `global-context-shell` coverage is required because tenant-prefilter and navigation continuity are part of the page contract +- **Closing validation and reviewer handoff**: rerun the four focused commands above, verify the page stays read-only, and verify every CTA lands on an existing source surface with hidden tenants omitted from counts and labels +- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local unit plus feature increase +- **Review-stop questions**: lane fit, hidden fixture cost, accidental generic workflow helpers, source-page duplication risk +- **Escalation path**: `document-in-feature` for contained assembly-seam notes; `reject-or-split` if implementation introduces a generic task model or local mutation lane +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage +- **Why no dedicated follow-up spec is needed**: routine read-surface and navigation upkeep stays inside this feature unless implementation proves a structural need for a broader workflow engine + +## Rollout & Risk Controls + +- Keep the v1 audience anchored to existing workspace operators and tenant-entitled actors only. +- Treat the page as a routing surface. Do not add local claim, acknowledge, snooze, or follow-up mutation actions during implementation. +- Prefer extending existing source query seams over introducing new persisted or cross-domain workflow state. +- Keep navigation labels aligned with the source pages so the inbox reads as an entry surface, not a replacement shell. +- Validate the page with focused unit and feature coverage before considering any broader dashboard-entry or widget work. + +## Project Structure + +### Documentation (this feature) + +```text +specs/250-decision-governance-inbox/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── governance-inbox.openapi.yaml +└── tasks.md # Created later by /speckit.tasks, not by this plan step +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/ +│ │ │ ├── Findings/ +│ │ │ │ ├── MyFindingsInbox.php +│ │ │ │ └── FindingsIntakeQueue.php +│ │ │ ├── Governance/ +│ │ │ │ └── GovernanceInbox.php # likely new page if implementation proceeds +│ │ │ ├── Monitoring/ +│ │ │ │ ├── Operations.php +│ │ │ │ └── Alerts.php +│ │ │ └── Operations/ +│ │ │ └── TenantlessOperationRunViewer.php +│ │ └── Resources/ +│ │ ├── AlertDeliveryResource.php +│ │ └── TenantReviewResource.php +│ ├── Models/ +│ │ ├── Finding.php +│ │ ├── OperationRun.php +│ │ ├── AlertDelivery.php +│ │ ├── TenantReview.php +│ │ └── TenantTriageReview.php +│ ├── Services/ +│ │ ├── PortfolioTriage/TenantTriageReviewService.php +│ │ └── TenantReviews/TenantReviewRegisterService.php +│ ├── Support/ +│ │ ├── Badges/ +│ │ ├── Filament/ +│ │ ├── GovernanceInbox/ # only if a bounded support seam is required +│ │ ├── Navigation/ +│ │ ├── OperateHub/ +│ │ ├── OperationRunLinks.php +│ │ ├── Rbac/ +│ │ └── Ui/ActionSurface/ +│ └── Policies/ +├── bootstrap/providers.php +├── resources/views/filament/pages/governance/ # likely new page view if implementation proceeds +└── tests/ + ├── Feature/Governance/ + └── Unit/Support/GovernanceInbox/ +``` + +**Structure Decision**: Laravel monolith. Implementation should stay entirely inside `apps/platform`, add at most one new read-only page and matching view, and reuse existing source-page routing, RBAC, and status semantics rather than creating a separate workflow subsystem. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| BLOAT-001 - bounded section or entry assembler | one page still needs deterministic cross-family section ordering and source-surface links | inline page composition alone risks duplicated ordering rules and unreadable page code once five families are involved | + +## Proportionality Review + +- **Current operator problem**: operators cannot decide what needs attention first from one workspace surface despite the repo already having real findings, alerts, operations, and review-follow-up truth. +- **Existing structure is insufficient because**: current pages answer only one family each and force entity-first reconstruction before the operator can act. +- **Narrowest correct implementation**: add one read-only workspace inbox page over existing source-page queries and routing seams, with at most one bounded derived section or entry assembly helper. +- **Ownership cost created**: one page, one view, one bounded derived assembly seam, and focused unit plus feature coverage. +- **Alternative intentionally rejected**: a persisted inbox-item table or generic task engine was rejected because it adds durable workflow truth before the read-only decision surface is proven. +- **Release truth**: current-release workflow compression, not future workboard preparation. + +## Phase 0 — Research (output: research.md) + +Research resolved the remaining implementation-shaping decisions: + +- choose a section-based composition page over a polymorphic task table or persisted queue +- reuse findings queue semantics from `MyFindingsInbox` and `FindingsIntakeQueue` +- reuse stale or terminal-follow-up operation semantics from `Operations` +- treat alert-delivery failures as the narrow alert-family slice for v1 instead of alert-rule configuration +- reuse `TenantTriageReview` follow-up truth for review-family attention +- rely on `CanonicalNavigationContext` and `OperationRunLinks` for drill-through continuity + +**Output**: [research.md](research.md) + +## Phase 1 — Design (outputs: data-model.md, contracts/, quickstart.md) + +Design artifacts capture the narrow implementation shape: + +- existing persisted truth reused: findings, operation runs, alert deliveries, tenant reviews, and triage reviews +- new code-owned truth limited to derived inbox sections and preview entries only +- conceptual contract covers one workspace page with optional tenant and family filters plus source-surface links +- quickstart documents the intended slice order, validation commands, and read-only posture + +**Artifacts**: + +- [data-model.md](data-model.md) +- [contracts/governance-inbox.openapi.yaml](contracts/governance-inbox.openapi.yaml) +- [quickstart.md](quickstart.md) + +## Phase 2 — Planning (for tasks.md) + +Dependency-ordered implementation outline for the later `tasks.md` step: + +1. Add the native governance inbox page shell and read-only view in the admin plane. +2. Resolve the bounded section assembly seam, preferring reuse of source-page query rules over a new workflow subsystem. +3. Add family sections for assigned findings, intake, stale operations, alert-delivery failures, and triage follow-up. +4. Reuse `CanonicalNavigationContext`, `RelatedNavigationResolver`, and `OperationRunLinks` for every drill-through path. +5. Add tenant and family filter state with honest empty-state behavior and `404` handling for explicit out-of-scope tenant targeting. +6. Add focused unit and feature tests only; no browser, queue, or heavy-governance family is expected. + +## Guardrail / Exception / Smoke Coverage + +- Guardrail result: PASS. Filament remains v5 on Livewire v4, panel provider registration stays unchanged in `apps/platform/bootstrap/providers.php`, the slice adds no new globally searchable Resource, no destructive inbox action, and no new registered asset bundle. Deployment asset handling stays unchanged: `cd apps/platform && php artisan filament:assets` only matters if future registered assets are introduced. +- Shared seam outcome: `document-in-feature`. A bounded derived helper was required as `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` because the existing source pages did not expose a reusable cross-family inbox API. The seam stayed page-scoped and read-only; no persisted inbox state or generic workflow engine was introduced. +- Source CTA outcome: PASS. Assigned findings route to `MyFindingsInbox` and tenant finding detail, intake routes to `FindingsIntakeQueue` and tenant finding detail, operations route through `OperationRunLinks` into the canonical tenantless monitoring detail, alerts route to `AlertDeliveryResource` index or view, and review follow-up routes into the existing tenant review or customer review surfaces. The inbox page itself remains mutation-free. +- Filter and authorization outcome: PASS. Workspace membership remains the first gate, explicit out-of-scope tenant filters still resolve as `404`, in-scope members with no visible families still receive `403`, and tenant or family filters stay query-only and capability-safe. +- Validation lane result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php tests/Feature/Governance/GovernanceInboxAuthorizationTest.php tests/Feature/Governance/GovernanceInboxPageTest.php tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` passed with `10 passed (53 assertions)`. +- Smoke evidence: integrated-browser smoke on `http://localhost/admin/governance/inbox` passed in an authenticated workspace session. The inbox loaded successfully, the operations-family CTA opened the canonical `/admin/operations` route with `problemClass=terminal_follow_up` plus the shared `nav` payload, the monitoring page rendered a visible `Back to governance inbox` control, and that return link brought the session back to `/admin/governance/inbox`. +- Formatting result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` passed. +- Review outcome class: `acceptable-special-case`. +- Workflow outcome: `keep`. +- Exception note: none beyond the bounded section-builder seam already recorded above. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/quickstart.md b/specs/250-decision-governance-inbox/quickstart.md new file mode 100644 index 00000000..67464ec8 --- /dev/null +++ b/specs/250-decision-governance-inbox/quickstart.md @@ -0,0 +1,65 @@ +# Quickstart: Decision-Based Governance Inbox v1 + +**Date**: 2026-04-28 +**Feature**: [spec.md](spec.md) + +## Purpose + +This quickstart captures the smallest intended implementation and validation path for the governance inbox slice. It is preparation-only guidance for later implementation work. + +## Planned Implementation Shape + +1. Add one native Filament page at `/admin/governance/inbox`. +2. Compose five bounded source families from existing repo truth: + - assigned findings + - findings intake + - stale or terminal-follow-up operations + - alert-delivery failures + - review follow-up +3. Keep the page read-only and route every action into an existing source surface. +4. Keep tenant and family filters query-safe and workspace-safe. + +## Planned Validation Commands + +Run the minimum proving commands once implementation exists: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxAuthorizationTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +## Manual Review Checklist For Later Implementation + +- Open `/admin/governance/inbox` as a workspace operator with at least two visible signal families. +- Verify the page stays read-only and does not offer claim, snooze, acknowledge, assign, or triage mutation controls. +- Verify a tenant-scoped launch prefilters the page to the current tenant. +- Verify explicit out-of-scope `tenant_id` query input returns `404`. +- Verify each visible section opens an existing source surface and preserves a back-link or source context. + +## Guardrails To Preserve + +- No new persisted inbox-item table. +- No generic cross-domain task engine. +- No browser-only validation requirement by default. +- No raw-support or debug detail rendered on the inbox page. + +## Close-Out Target For Later Implementation + +Record the final outcome in `Guardrail / Exception / Smoke Coverage` once implementation happens, including: + +- whether a bounded `Support/GovernanceInbox/` seam was actually needed +- whether all source CTAs stayed on existing canonical surfaces +- whether any contained drift resolved as `document-in-feature` +- the final proof outcome from the focused unit and feature validation commands + +## Guardrail / Exception / Smoke Coverage + +- Guardrail result: PASS. The implemented slice stayed on the existing Filament v5 / Livewire v4 admin plane, kept provider registration untouched in `apps/platform/bootstrap/providers.php`, introduced no destructive inbox action, and added no new registered asset bundle. +- Bounded seam result: `document-in-feature`. The final implementation required `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` as a derived page-scoped assembler because the current source pages did not expose a reusable cross-family API. +- Source-surface result: PASS. All dominant section CTAs and preview-entry links stayed on existing findings, operations, alerts, and review surfaces; no inbox-local mutation lane or detail shell was added. +- Focused proof result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php tests/Feature/Governance/GovernanceInboxAuthorizationTest.php tests/Feature/Governance/GovernanceInboxPageTest.php tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` passed with `10 passed (53 assertions)`. +- Formatting result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` passed. +- Smoke result: PASS. Manual integrated-browser smoke confirmed `/admin/governance/inbox` loads in workspace context, the operations CTA navigates to the canonical monitoring route with return context, and the explicit back link returns to the inbox. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/research.md b/specs/250-decision-governance-inbox/research.md new file mode 100644 index 00000000..152d75cd --- /dev/null +++ b/specs/250-decision-governance-inbox/research.md @@ -0,0 +1,104 @@ +# Research: Decision-Based Governance Inbox v1 + +**Date**: 2026-04-28 +**Feature**: [spec.md](spec.md) + +## Decision Summary + +The repo already contains the underlying governance attention signals. The missing product slice is not another source page or another workflow state, but one bounded decision-first page that composes the existing source seams into a calm workspace starting point. + +## Key Decisions + +### 1. Use section-based composition, not a generic task engine + +- **Decision**: Build the inbox as one read-only Filament page with bounded family sections and preview entries. +- **Why**: A polymorphic table or persisted inbox-item model would import a second workflow truth before the first read-only operator surface is proven. +- **Repo truth**: Findings, operations, alerts, and review follow-up already have their own truthful pages and models. + +### 2. Reuse findings queue semantics directly + +- **Decision**: The assigned-findings and intake sections should reuse the inclusion and urgency rules already owned by `MyFindingsInbox` and `FindingsIntakeQueue`. +- **Why**: Those pages already codify open-status filtering, tenant entitlement, urgency ordering, and calm empty states. +- **Source seams**: + - `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` + - `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` + +### 3. Use stale or terminal-follow-up operations as the operations-family signal + +- **Decision**: The operations section should derive from the same stale or follow-up attention rules already exposed on the canonical `Operations` page. +- **Why**: The repo already has a canonical operations monitoring surface and run-detail route; the inbox should route into it instead of inventing a second operations diagnostic layer. +- **Source seams**: + - `apps/platform/app/Filament/Pages/Monitoring/Operations.php` + - `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` + - `apps/platform/app/Support/OperationRunLinks.php` + +### 4. Keep the alert-family slice narrow: failed alert deliveries, not alert-rule config + +- **Decision**: The alerts section should surface delivery failures or similar operator-attention alert outcomes, not alert-rule configuration. +- **Why**: Delivery failure is the actionable alerting gap that belongs in an attention inbox. Alert-rule editing stays a configuration workflow on its existing surfaces. +- **Source seams**: + - `apps/platform/app/Filament/Pages/Monitoring/Alerts.php` + - `apps/platform/app/Filament/Resources/AlertDeliveryResource.php` + - `apps/platform/app/Models/AlertDelivery.php` + +### 5. Use triage-review follow-up as the review-family signal + +- **Decision**: The review section should derive from `TenantTriageReview` states such as `follow_up_needed` and changed-since-review semantics. +- **Why**: The repo already distinguishes review follow-up from the underlying review artifact; the inbox should reuse that distinction rather than invent a second attention reason model. +- **Source seams**: + - `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php` + - `apps/platform/app/Models/TenantTriageReview.php` + - `apps/platform/app/Filament/Resources/TenantReviewResource.php` + +### 6. Preserve navigation continuity through shared context helpers + +- **Decision**: Every section and preview entry should use existing navigation helpers for back links and canonical destinations. +- **Why**: The inbox only reduces attention load if it preserves return context instead of opening detached utility flows. +- **Source seams**: + - `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` + - `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php` + - `apps/platform/app/Support/OperateHub/OperateHubShell.php` + +### 7. Keep the inbox read-only in v1 + +- **Decision**: No claim, snooze, acknowledge, assign, or triage mutations are introduced on the inbox page. +- **Why**: Those mutations already belong to source surfaces and would force the inbox to become a second workflow owner. +- **Result**: The inbox remains a decision hub, not an execution surface. + +## Access Model Decision + +- Workspace membership remains the first gate. +- The page only needs to exist for actors who can already see at least one family. +- Rows and counts must stay family-specific: + - findings sections require `Capabilities::TENANT_FINDINGS_VIEW` + - review follow-up requires `Capabilities::TENANT_REVIEW_VIEW` + - alert-family sections require `Capabilities::ALERTS_VIEW` + - source mutations remain on source pages with their existing capabilities +- Explicit out-of-scope `tenant_id` inputs return `404`. + +## Rejected Alternatives + +### Rejected: persisted inbox-item table + +- **Reason**: adds durable workflow truth, migration cost, audit burden, and new lifecycle semantics before the read-only composition page is proven. + +### Rejected: generic cross-domain work-item abstraction + +- **Reason**: over-generalizes five concrete families into a second vocabulary and invites a platform-level task framework that current-release truth does not require. + +### Rejected: extend one existing page instead of adding a canonical inbox + +- **Reason**: no single existing page can truthfully host all five families without becoming the wrong domain owner. + +## Implications For Implementation + +- Prefer one bounded `Support/GovernanceInbox/` seam only if page-local composition becomes unreadable. +- Keep source-family labels close to existing UI copy to avoid a second UX language. +- Keep empty states honest: + - tenant-prefilter-hidden attention -> `Clear tenant filter` + - globally calm -> one neutral workspace return CTA +- Do not add page-level audit noise for mere page views. + +## Planning Outcome + +The smallest viable implementation slice is one new read-only workspace page that reuses existing source-page queries, existing navigation helpers, and existing capability semantics. No new persistence or mutation lane is justified. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/spec.md b/specs/250-decision-governance-inbox/spec.md new file mode 100644 index 00000000..4d46a6fc --- /dev/null +++ b/specs/250-decision-governance-inbox/spec.md @@ -0,0 +1,294 @@ +# Feature Specification: Decision-Based Governance Inbox v1 + +**Feature Branch**: `250-decision-governance-inbox` +**Created**: 2026-04-28 +**Status**: Draft +**Input**: User description: "Select the next best open spec candidate from roadmap and spec-candidates, then prepare a narrow repo-grounded Spec Kit package for a decision-oriented governance inbox that consolidates existing findings, alerts, stale operations, and portfolio triage signals without implementing application code." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot already has real findings queues, alerting, operations monitoring, and portfolio triage state, but operators still have to reconstruct what needs attention by moving across several surfaces before they can decide what to open next. +- **Today's failure**: The product still behaves like an entity-first console instead of a decision-first work surface. Operators can miss stale operations, alert-delivery failures, review follow-up, or unassigned findings because each signal family lives on its own page. +- **User-visible improvement**: One canonical workspace inbox shows the most important governance attention from more than one existing signal family and routes the operator straight into the right existing execution or evidence surface. +- **Smallest enterprise-capable version**: One new read-first workspace page under `/admin` that aggregates existing assigned-findings, findings-intake, stale-operations, alert-delivery-failure, and review-follow-up signals into bounded sections with calm summaries and direct links into existing source pages. No new mutation lane ships on the inbox itself. +- **Explicit non-goals**: No replacement of `My Findings`, `Findings intake`, `Operations`, `Alerts`, or review-triage detail pages; no new persisted inbox-item table; no generic cross-domain task engine; no new acknowledge, snooze, or assignment state; no customer-facing inbox; no AI recommendations; no cross-workspace workboard. +- **Permanent complexity imported**: One canonical inbox page, one bounded derived section or entry assembly seam, one cross-family priority order, query-state handling for tenant and family filters, and focused unit plus feature coverage. +- **Why now**: The implementation ledger marks the missing decision inbox as a P0 workflow blocker immediately after Customer Review Workspace, while `spec-candidates.md` still lists it as P1. This package follows the stronger ledger urgency because the repo already has the underlying signal families, so the next product value is compression of operator attention, not another isolated source page. +- **Why not local**: Extending only `My Findings`, only `Operations`, or only `Alerts` would keep the current multi-page reconstruction problem intact and would not provide one truthful starting point for workspace attention. +- **Approval class**: Workflow Compression +- **Red flags triggered**: One mild `many surfaces` flag because the page composes several existing signal families. Defense: the slice stays read-only, introduces no new persistence, and explicitly reuses underlying source pages instead of replacing them. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view +- **Primary Routes**: + - new canonical workspace route `/admin/governance/inbox` + - existing `/admin/findings/my-work` + - existing `/admin/findings/intake` + - existing `/admin/operations` + - existing alerts cluster routes under `/admin/alerts/*` + - existing `/admin/reviews` and tenant-scoped review detail routes used for triage follow-up drill-through +- **Data Ownership**: + - tenant-owned `Finding`, `OperationRun`, `TenantReview`, and `TenantTriageReview` remain the only source of truth for their respective sections + - workspace-scoped `AlertDelivery`, `AlertRule`, and `AlertDestination` remain the alerting source of truth + - the governance inbox is a derived read surface only; it introduces no new table, cache, mirror entity, or workflow state +- **RBAC**: + - workspace membership remains the first access boundary for the inbox page + - page entry is allowed only when the actor is a workspace member and at least one source family is visible through existing capabilities + - non-members and explicit out-of-scope tenant targeting remain `404` deny-as-not-found boundaries + - in-scope workspace members who lack every qualifying source-family capability receive `403` instead of a silent empty shell + - assigned-findings and intake sections only include tenants where the actor has `Capabilities::TENANT_FINDINGS_VIEW` + - triage follow-up rows only include tenants where the actor has `Capabilities::TENANT_REVIEW_VIEW`; any follow-up mutation remains on the existing review surface and continues to require `Capabilities::TENANT_TRIAGE_REVIEW_MANAGE` + - alert-delivery failure sections only appear for actors who can access workspace alerts through `Capabilities::ALERTS_VIEW` + - operation-attention rows only appear when the actor could already open the underlying operation destination through the existing run and tenant entitlement rules + - the inbox itself is read-first; source-surface mutations such as claim, triage, acknowledge, or follow-up continue to enforce their existing server-side Gates or Policies + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: When launched from a tenant-scoped findings, review, or tenant dashboard surface, the inbox prefilters to that tenant while keeping the family filter on `All attention`. Operators may clear only the tenant prefilter to return to all visible attention across the workspace. +- **Explicit entitlement checks preventing cross-tenant leakage**: Explicit `tenant_id` inputs outside the actor's visible scope resolve as not found. Broad workspace listings silently omit inaccessible tenants, hidden signal families, and blocked drill-through targets from counts, labels, and empty-state hints. + +## 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 +- **Interaction class(es)**: navigation entry points, dashboard signals/cards, status messaging, action links, monitoring and governance drill-through, and badge semantics +- **Systems touched**: `MyFindingsInbox`, `FindingsIntakeQueue`, `Operations`, `Alerts`, `TenantReviewResource`, `TenantTriageReviewService`, `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, and existing alert, findings, and review source pages +- **Existing pattern(s) to extend**: native Filament workspace pages with tenant-prefilter state, existing queue summaries, `OperateHubShell` scope handling, `CanonicalNavigationContext` back-link continuity, and `ActionSurfaceDeclaration` documentation +- **Shared contract / presenter / builder / renderer to reuse**: `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, `CanonicalAdminTenantFilterState`, and the existing source-page query rules from `MyFindingsInbox`, `FindingsIntakeQueue`, `Operations`, `Alerts`, and review/triage services +- **Why the existing shared path is sufficient or insufficient**: Existing source pages already own the underlying truth and mutation semantics, but they are insufficient as a first decision surface because they only answer one family at a time. The inbox should compose those seams, not replace them. +- **Allowed deviation and why**: none planned. If implementation needs a bounded local section assembler, it must remain derived, page-scoped, and must not become a cross-product task framework. +- **Consistency impact**: Priority language, empty-state language, badge semantics, and drill-through labels must stay aligned with the existing source surfaces so the inbox feels like a routing layer over product truth rather than a parallel UX language. +- **Review focus**: Reviewers must block any implementation that duplicates local claim, acknowledge, triage, or stale-run mutation semantics on the inbox page or invents a second cross-domain workflow state. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: yes, deep-link only +- **Shared OperationRun UX contract/layer reused**: `OperationRunLinks` plus the existing tenantless operation viewer path +- **Delegated start/completion UX behaviors**: canonical `Open operation` / run-detail URL resolution and existing operation-context navigation only; no new queued toast, run-enqueued event, or terminal-notification behavior is introduced +- **Local surface-owned behavior that remains**: the inbox only decides whether an operations attention section is shown and which existing run link is primary for that entry +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +N/A - no shared provider/platform boundary is widened. The inbox consumes already-normalized governance, alerts, operations, and review seams without introducing new provider-specific contracts. + +## 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 | +|---|---|---|---|---|---|---| +| Governance inbox page | yes | Native Filament page plus shared primitives | governance queues, monitoring drill-through, navigation continuity, badge/status reuse | page, URL-query | no | One new canonical read-only decision surface; source pages remain authoritative | + +## 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 | +|---|---|---|---|---|---|---|---| +| Governance inbox page | Primary Decision Surface | Operator opens the workspace and decides which existing governance surface needs attention first | visible attention by family, tenant scope, urgency, count, and dominant next action | full source detail, operation detail, alerts context, and review/finding evidence only after opening the source surface | Primary because it becomes the first workspace attention surface across more than one signal family | Follows the operator question `what needs attention now?` before the entity-specific question `what does this record contain?` | Replaces multi-page search across findings, alerts, operations, and review follow-up with one calm starting point | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Governance inbox page | operator-MSP | family summary, top attention entries, urgency cues, tenant scope, and direct next action into the existing source surface | source-specific detail remains on `My Findings`, `Findings intake`, `Operations`, `Alerts`, and review surfaces | raw payloads, alert body details, operation diagnostics, and evidence payloads stay on existing source pages and remain capability-gated there | `Open attention source` per section or entry | raw/support detail is not rendered on the inbox page | the inbox states the decision truth once, then relies on source pages for proof rather than re-explaining the same blocker in parallel blocks | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Governance inbox page | Utility / Workspace Decision | Read-only workflow hub | Open the existing source surface for the highest-priority attention family or entry | explicit section or preview-entry CTA into the underlying source surface | forbidden | section footers or preview-entry links only | none | `/admin/governance/inbox` | existing source-specific routes, including `/admin/findings/my-work`, `/admin/findings/intake`, `/admin/operations/{run}` for entry-level operations drill-through, alerts cluster routes, and review routes | active workspace, optional tenant prefilter, family filter | Governance inbox | which attention family needs action now and where the operator should go next | 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Governance inbox page | Workspace operator / MSP operator | Decide which existing governance surface should be opened next | Workspace decision hub | What needs attention right now across my visible governance surfaces, and where should I go to act? | section counts, top items, tenant label when applicable, urgency cues, family label, and source CTA | source-specific reason detail, evidence, alert metadata, and full operation diagnostics remain on source surfaces | urgency, source family, tenant scope, follow-up state, delivery failure state, stale/terminal attention state | none on the inbox page itself | Open my findings, Open intake, Open operation, Open alerts, Open review follow-up | 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 section or entry assembly seam may be needed to compose multi-family attention into one page +- **New enum/state/reason family?**: no persisted family; any family keys remain local derived page constants only +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: operators cannot answer `what needs attention now?` from one workspace surface, even though the repo already has real findings, alerts, operations, and review-follow-up truth +- **Existing structure is insufficient because**: current pages answer only one family each and force entity-first reconstruction before the operator can act +- **Narrowest correct implementation**: one read-only workspace page that derives its sections from existing source-page query semantics and routes operators into the existing source surfaces +- **Ownership cost**: one new page, one bounded derived assembly seam, tenant and family query-state handling, and focused unit plus feature coverage +- **Alternative intentionally rejected**: a generic cross-product task engine or persisted inbox-item table was rejected because it would import new workflow truth before the read-only decision surface is proven +- **Release truth**: current-release workflow compression, not future-release workboard infrastructure + +### 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**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: unit coverage proves attention-family assembly, ordering, and source-link decisions cheaply; focused feature coverage proves workspace membership, per-family visibility, tenant-prefilter behavior, and navigation continuity on a native Filament page. Browser coverage is not the narrowest honest proof for this slice. +- **New or expanded test families**: one focused `GovernanceInbox` feature family and one focused `Unit/Support/GovernanceInbox` family +- **Fixture / helper cost impact**: moderate; tests need visible and hidden tenants, findings in assigned and intake states, stale or terminal-follow-up runs, failed alert deliveries, and triage review states, but should reuse existing factories and avoid browser or heavy-governance setup +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: global-context-shell +- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient once explicit tests prove tenant-prefilter state, family omission, and source-surface navigation context +- **Reviewer handoff**: reviewers must confirm that hidden tenant signals never leak into counts or labels, the page stays read-only, and every CTA lands on an existing source surface rather than a new local mutation lane +- **Budget / baseline / trend impact**: low feature-local increase only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` + +## Scope Boundaries + +### In Scope + +- one canonical workspace-level governance inbox page in the existing admin plane +- bounded attention sections for assigned findings, findings intake, stale or terminal-follow-up operations, alert-delivery failures, and review follow-up signals +- calm counts and top-entry previews per visible family +- existing source-surface links with preserved navigation context +- tenant and family filters with honest empty-state behavior + +### Non-Goals + +- replacing or hiding the existing source pages that already own findings, operations, alerts, or review state +- new acknowledge, snooze, claim, triage, or assign actions on the inbox page +- a new persisted inbox-item or work-state table +- cross-workspace or customer-facing inboxes +- AI prioritization, autonomous routing, or recommendation logic +- raw-support or debug detail on the inbox page itself + +## Assumptions + +- existing source pages already expose enough truth to derive section counts and top previews without introducing a second workflow state +- alert-delivery failures are the narrowest alert-family attention slice for v1; alert-rule configuration remains secondary +- existing `CanonicalNavigationContext` and `OperationRunLinks` seams are sufficient for return-link continuity +- the page can stay useful even when only a subset of families is visible for the current actor + +## Risks + +- a single mixed attention list could tempt implementation toward a new generic task model; this must be resisted in favor of bounded section composition +- some operations or alert items may be workspace-scoped while other families are tenant-scoped, which increases the chance of misleading empty states if filter logic is not explicit +- if the page tries to surface too much source detail, it can become a duplicate of the underlying pages instead of a decision hub + +## Follow-up Candidates + +- bounded acknowledge or snooze semantics once a real cross-family attention state exists in the product +- dashboard or workspace-overview entry signals into the governance inbox after the canonical page is proven +- a broader decision-based operating system slice only after the first read-only inbox is adopted successfully + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - See Multi-Family Attention In One Place (Priority: P1) + +As a workspace operator, I want one inbox that shows the most important governance attention across more than one signal family so I can decide where to work next without scanning multiple pages first. + +**Why this priority**: This is the core missing value. Without a multi-family attention surface, the product still forces page-hopping before any decision can be made. + +**Independent Test**: Seed visible assigned findings, intake findings, stale operations, alert-delivery failures, and triage follow-up. Open the inbox and verify that the page shows more than one visible family with calm counts and top entries. + +**Acceptance Scenarios**: + +1. **Given** the actor has visible assigned findings and stale operations, **When** they open the governance inbox, **Then** both families appear with separate counts, urgency cues, and one dominant source CTA each. +2. **Given** the actor can access only findings and not alerts, **When** they open the governance inbox, **Then** alert sections, labels, and counts do not appear at all. +3. **Given** no visible attention exists in any accessible family, **When** they open the governance inbox, **Then** the page shows one calm empty state and does not imply hidden work exists elsewhere. + +--- + +### User Story 2 - Open The Right Existing Source Surface With Context (Priority: P1) + +As a workspace operator, I want the inbox to route me into the correct existing page with preserved context so the inbox stays a decision hub and not a duplicate execution surface. + +**Why this priority**: The page only reduces attention load if the next click is obvious and lands in the existing product truth. + +**Independent Test**: Open the governance inbox, choose one attention entry from findings, operations, and review follow-up, and verify that each CTA lands on the correct existing destination with back-link or context continuity preserved. + +**Acceptance Scenarios**: + +1. **Given** an assigned-findings section is visible, **When** the actor chooses its dominant action, **Then** the destination opens the existing `My Findings` or tenant finding detail surface instead of a new local inbox detail shell. +2. **Given** an operations attention entry is visible, **When** the actor opens it, **Then** the destination uses the canonical operation URL path and preserves a return path back to the inbox. +3. **Given** a review follow-up section is visible, **When** the actor opens it, **Then** the destination lands on the existing review or triage surface rather than a duplicate summary on the inbox page. + +--- + +### User Story 3 - Filter The Inbox Honestly Without Leakage (Priority: P2) + +As a workspace operator, I want the governance inbox to respect tenant context and family filters without leaking hidden tenants, hidden families, or inaccessible records. + +**Why this priority**: A decision hub is dangerous if it implies missing or hidden work incorrectly or if it leaks cross-tenant state through filter labels or empty-state hints. + +**Independent Test**: Open the inbox with an active tenant context, with an explicit family filter, and with an inaccessible tenant query parameter. Verify the resulting rows, counts, and empty states are truthful and capability-safe. + +**Acceptance Scenarios**: + +1. **Given** an active tenant context exists, **When** the actor opens the governance inbox, **Then** the page prefilters to that tenant and allows the actor to clear only the tenant prefilter back to all visible attention. +2. **Given** a `tenant_id` query parameter references a tenant outside the actor's scope, **When** the governance inbox loads, **Then** the request resolves as not found instead of rendering an empty or hinting state. +3. **Given** the actor applies a family filter for one accessible family, **When** the page renders, **Then** counts, previews, and empty-state copy describe only that visible family and do not mention hidden families. + +### Edge Cases + +- a single tenant may contribute more than one visible family at once; the inbox must keep those families separate instead of inventing a merged workflow state +- alert-delivery failure rows may be workspace-scoped and tenantless; the page must not fabricate tenant labels or tenant-only actions for them +- an operation run may remain in the workspace database after the actor loses tenant entitlement; the inbox must omit it rather than leak stale references +- a tenant prefilter can hide otherwise visible attention in other tenants; the empty state must explain the tenant boundary honestly before claiming the workspace is calm + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature adds no Microsoft Graph call, no new queue start, no new `OperationRun`, and no new persisted truth. It adds one derived read-only decision surface over existing findings, alerts, operations, and review-triage truth. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The inbox must stay derived. It must not create a new task engine, persisted attention table, or cross-domain workflow state. Any new assembly seam must remain bounded to page composition and reuse existing source-state semantics. + +**Constitution alignment (XCUT-001):** The inbox must extend existing shared navigation, badge, and source-surface patterns rather than inventing a parallel interaction family for claim, acknowledge, stale-run handling, or review follow-up. + +**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** The inbox must remain decision-first. Default-visible content is family, urgency, scope, and next action only. Diagnostics, evidence, and raw-support details stay on the source pages. + +**Constitution alignment (TEST-GOV-001):** The implementation must stay in focused `Unit` and `Feature` lanes. No browser or heavy-governance family is justified by default for this slice. + +**Constitution alignment (RBAC-UX):** Workspace membership remains the first boundary. Explicit out-of-scope tenant filters return `404`. Once workspace membership is established, missing per-family capabilities continue to suppress rows or source actions instead of leaking inaccessible truth. + +**Constitution alignment (RBAC-UX - page access):** Non-members and out-of-scope tenant targeting return `404`, while in-scope workspace members who lack every qualifying family capability receive `403` on page access. + +### Functional Requirements + +- **FR-001**: The system MUST provide a canonical governance inbox at `/admin/governance/inbox` inside the existing admin plane. +- **FR-002**: The inbox MUST aggregate visible attention from more than one underlying signal family using existing product truth rather than a new persisted workflow state. +- **FR-003**: The first supported attention families in v1 MUST be assigned findings, findings intake, stale or terminal-follow-up operations, alert-delivery failures, and review follow-up. +- **FR-004**: The inbox MUST remain read-first. It MUST route to existing source surfaces for claim, triage, operation review, alert drill-through, or review follow-up instead of re-implementing those mutations locally. +- **FR-005**: The inbox MUST expose family counts, top attention previews, tenant scope when applicable, and one dominant source CTA per visible section. +- **FR-006**: The inbox MUST support an optional tenant prefilter and optional family filter. When tenant context is active, the tenant prefilter is applied by default. +- **FR-007**: The inbox MUST omit inaccessible tenants, inaccessible families, and inaccessible source actions from counts, labels, empty-state hints, and preview content. +- **FR-008**: If the actor explicitly targets an out-of-scope tenant through query state, the inbox MUST return `404` deny-as-not-found semantics. +- **FR-009**: Operation-related entries MUST reuse canonical run URLs and existing operation lifecycle semantics instead of inventing local stale-run logic. +- **FR-010**: Alert-related entries MUST derive from existing alert delivery or alert overview truth and MUST NOT duplicate alert-rule configuration state as work items. +- **FR-011**: Review-follow-up entries MUST derive from existing tenant review and triage-review truth and MUST NOT create a second follow-up state family. +- **FR-012**: The inbox MUST NOT introduce a new globally searchable resource, a new panel, or a new asset bundle for v1. +- **FR-013**: The inbox MUST enforce `404` for non-members and explicit out-of-scope tenant targeting, and `403` for in-scope workspace members who lack any qualifying visible-family capability. + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Governance inbox page | `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` | `Clear tenant filter` only when a tenant prefilter is active | explicit section and preview-entry CTA into existing source surfaces; no local detail shell | none | none | `Clear tenant filter` when the tenant filter alone hides attention; otherwise `Open workspace dashboard` | n/a | n/a | no direct audit; page stays read-only | Action Surface Contract stays satisfied because the page has one dominant navigation goal and no local mutation lane | + +### Key Entities *(include if feature involves data)* + +- **Governance inbox section**: A derived grouping for one source family that carries a title, visible count, dominant next action, and top previews. +- **Governance attention entry**: A derived preview item that points to one existing source surface and carries only the minimal status, scope, and urgency information needed for the next click. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In acceptance review, an operator can determine within 15 seconds whether assigned findings, intake findings, stale operations, alert-delivery failures, or review follow-up require attention from one page. +- **SC-002**: 100% of covered automated tests show that hidden tenants and hidden families do not leak into counts, labels, or empty-state hints. +- **SC-003**: 100% of covered automated tests show that each visible family routes to an existing canonical source surface rather than a new local mutation or detail shell. +- **SC-004**: With seeded workspace data from at least two signal families, the inbox can show both on one page without introducing a new persisted workflow state. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/tasks.md b/specs/250-decision-governance-inbox/tasks.md new file mode 100644 index 00000000..189fd67d --- /dev/null +++ b/specs/250-decision-governance-inbox/tasks.md @@ -0,0 +1,173 @@ +--- + +description: "Task list for Decision-Based Governance Inbox v1" + +--- + +# Tasks: Decision-Based Governance Inbox v1 + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` + +**Tests**: Required (Pest). Keep proof in focused `Unit` and `Feature` lanes only, using the targeted Sail commands already captured in the feature artifacts. +**Operations**: The inbox introduces no new `OperationRun`, queue, or result ledger. It only deep-links into existing run detail surfaces through the shared operation-link contract. +**RBAC**: Workspace membership remains the first gate. Explicit out-of-scope tenant filters remain `404`. Source-family rows and source-family destinations stay capability-gated through existing registries and policies. +**Organization**: Tasks are grouped by user story so the multi-family read surface, source-surface routing, and filter-safety behavior remain independently testable after the shared foundation is in place. + +## Test Governance Checklist + +- [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior. +- [x] New or changed tests stay under `apps/platform/tests/Unit/Support/GovernanceInbox/` and `apps/platform/tests/Feature/Governance/` only; no browser or heavy-governance lane is added. +- [x] Shared helpers, factories, fixtures, and context defaults stay cheap by default; do not add a generic workflow fixture or seeded inbox-item state. +- [x] Planned validation commands cover section assembly, page access, and navigation continuity without pulling in unrelated lane cost. +- [x] The declared surface test profile remains `global-context-shell` because tenant-prefilter and navigation continuity are part of the page contract. +- [x] Any bounded assembly-seam drift resolves as `document-in-feature` unless implementation proves a structural workflow-engine need. + +## Phase 1: Setup (Shared Context) + +**Purpose**: Confirm the bounded slice, source seams, and reviewer stop conditions before runtime implementation begins. + +- [x] T001 Review the bounded slice, explicit non-goals, and guardrail expectations in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/checklists/requirements.md` +- [x] T002 [P] Review the implementation-shaping decisions in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` +- [x] T003 [P] Confirm the source-page seams that must remain authoritative: `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Pages/Monitoring/Alerts.php`, `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`, `apps/platform/app/Filament/Resources/TenantReviewResource.php`, and `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish the read-only page shell, authorization boundaries, and bounded assembly seam that every user story depends on. + +**Critical**: No user story work should begin until this phase is complete. + +- [x] T004 [P] Add focused authorization coverage for workspace membership, explicit out-of-scope tenant-prefilter `404` behavior, in-scope member `403` behavior when no qualifying family capability exists, and family-level omission rules in `apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` +- [x] T005 Create the native governance inbox page shell and Blade view in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`, keeping the surface read-only and inside the admin plane +- [x] T006 Resolve the section-assembly seam by reusing existing source-page query rules first; only if the page becomes unreadable, add a bounded helper under `apps/platform/app/Support/GovernanceInbox/` and record the choice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` +- [x] T007 [P] Thread tenant and family filter state plus navigation context through `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, reusing `CanonicalNavigationContext` and `CanonicalAdminTenantFilterState` rather than introducing a page-local state system + +**Checkpoint**: The inbox page shell, page access rules, and bounded assembly decision exist. User-story work can now proceed independently. + +--- + +## Phase 3: User Story 1 - See Multi-Family Attention In One Place (Priority: P1) MVP + +**Goal**: Let a workspace operator see more than one visible signal family from one decision-first page without introducing a second workflow state. + +**Independent Test**: Seed visible assigned findings, intake findings, stale operations, alert-delivery failures, and review follow-up, then verify the inbox shows calm section summaries and top previews from more than one family. + +### Tests for User Story 1 + +- [x] T008 [P] [US1] Add unit coverage for derived section and preview-entry assembly in `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` +- [x] T009 [P] [US1] Add feature coverage for multi-family page rendering, calm counts, and honest global empty-state behavior in `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php` + +### Implementation for User Story 1 + +- [x] T010 [US1] Derive the assigned-findings and intake sections from the existing query semantics in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` and `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` without introducing new workflow-state constants +- [x] T011 [US1] Derive the operations and alerts sections from `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Pages/Monitoring/Alerts.php`, and `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`, keeping the alert-family slice focused on delivery-failure attention rather than alert-rule configuration +- [x] T012 [US1] Derive the review follow-up section from `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`, `apps/platform/app/Models/TenantTriageReview.php`, and the existing review register truth, then render all visible sections on `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` + +**Checkpoint**: User Story 1 is independently functional when more than one visible family can appear on the inbox page without new persisted workflow state. + +--- + +## Phase 4: User Story 2 - Open The Right Existing Source Surface With Context (Priority: P1) + +**Goal**: Route every visible section and preview entry into the correct existing source surface so the inbox stays a decision hub rather than becoming a duplicate execution shell. + +**Independent Test**: Open the inbox, use findings, operations, alerts, and review-follow-up CTAs, and verify each destination is an existing canonical source route with preserved return or source context. + +### Tests for User Story 2 + +- [x] T013 [P] [US2] Add focused navigation-context coverage for source-surface CTAs and back-link continuity in `apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` + +### Implementation for User Story 2 + +- [x] T014 [US2] Route findings and review-follow-up sections through existing source pages using `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and the existing resource URL helpers on `apps/platform/app/Filament/Resources/TenantReviewResource.php` +- [x] T015 [US2] Route operation attention entries through `apps/platform/app/Support/OperationRunLinks.php` and the canonical tenantless operation detail route `/admin/operations/{run}` instead of inventing a new inbox-local detail shell +- [x] T016 [US2] Keep the inbox read-only by ensuring claim, triage, acknowledge, snooze, and follow-up mutations remain on their source surfaces; if any source surface needs small back-link hardening, change the smallest source page rather than adding local mutations on `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` + +**Checkpoint**: User Story 2 is independently functional when every visible CTA lands on an existing source surface with preserved context and the inbox still owns no mutations. + +--- + +## Phase 5: User Story 3 - Filter The Inbox Honestly Without Leakage (Priority: P2) + +**Goal**: Keep tenant and family filtering honest so the inbox never leaks hidden tenants, hidden families, or inaccessible source destinations. + +**Independent Test**: Load the inbox with an active tenant context, a family filter, and an explicit hidden tenant query parameter, then verify the resulting counts, labels, and empty states are truthful. + +### Tests for User Story 3 + +- [x] T017 [P] [US3] Extend feature coverage for tenant-prefilter state, family filters, hidden-family omission, and tenant-specific empty-state branches in `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php` + +### Implementation for User Story 3 + +- [x] T018 [US3] Add family and tenant filter handling to `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, keeping active tenant context durable and clearable without inventing a second filter persistence system +- [x] T019 [US3] Ensure hidden tenants and hidden families never contribute to section counts, preview labels, or empty-state hints, and keep tenantless alert or operations entries truthful when rendered on `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` + +**Checkpoint**: User Story 3 is independently functional when tenant and family filters remain capability-safe and globally honest. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finish narrow validation, formatting, and reviewer close-out without widening scope. + +- [x] T020 [P] Run the focused unit validation command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` against `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` +- [x] T021 [P] Run the focused page and authorization validation commands from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` against `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php` and `apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` +- [x] T022 [P] Run the focused navigation-context validation command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` against `apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` +- [x] T023 Run dirty-only Pint for touched platform files using the command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` +- [x] T024 Record the final `Guardrail / Exception / Smoke Coverage` close-out, including whether a bounded `Support/GovernanceInbox/` seam was needed and whether any contained drift resolved as `document-in-feature`, in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/checklists/requirements.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: no dependencies; start immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user-story work. +- **Phase 3 (US1)**: depends on Phase 2 and establishes the first independently valuable slice. +- **Phase 4 (US2)**: depends on Phase 2 and is safest after US1 because it reuses the same page and view files. +- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 because filter and empty-state behavior depend on the final visible sections. +- **Phase 6 (Polish)**: depends on the stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: independently shippable as the minimal read-only decision surface once Phase 2 is complete. +- **US2 (P1)**: independently testable after Phase 2, but should merge after US1 because the same page composition and routing files are shared hotspots. +- **US3 (P2)**: independently testable after Phase 2, but should merge after US1 because family and tenant filters depend on the visible section set. + +### Within Each User Story + +- Write the listed Pest coverage first and make it fail for the intended gap. +- Finish shared query or routing reuse before widening the page view. +- Re-run the narrowest relevant proof command after each story checkpoint before moving to the next story. + +--- + +## Implementation Strategy + +### Suggested MVP Scope + +- First shippable slice = **Phase 2 + User Story 1 + User Story 2**. That delivers the canonical decision-first inbox page with the required multi-family attention surface and the required routing into existing source surfaces. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 and validate the multi-family read surface. +3. Deliver US2 and validate that all CTAs land on existing source surfaces. +4. Deliver US3 and validate filter honesty plus `404` handling. +5. Finish with Phase 6 validation, formatting, and close-out recording. + +### Team Strategy + +1. Finish Phase 2 together before splitting story work. +2. Parallelize unit and feature test authoring inside each story first. +3. Serialize merges touching `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`, because they are the main conflict hotspots for this slice. + +## Notes + +- [P] tasks should stay on different files or clearly isolated seams. +- Each story remains independently testable, but the first shippable slice includes both US1 and US2 because routing into existing source surfaces is part of the required product contract. +- Re-run the narrowest relevant Pest command after each story checkpoint before moving forward. +- Stop at each checkpoint if the page starts drifting toward a generic workflow engine or local mutation lane. \ No newline at end of file -- 2.45.2 From 7ee4909212531e89ace89ca9d749dff91f711c3d Mon Sep 17 00:00:00 2001 From: ahmido Date: Tue, 28 Apr 2026 13:39:33 +0000 Subject: [PATCH 25/36] feat: commercial lifecycle overlay for workspace entitlements (#292) ## Summary - add the bounded workspace commercial lifecycle overlay from spec 251 on top of the existing entitlement substrate - expose audited commercial state inspection and mutation on the system workspace detail surface - gate onboarding activation and review-pack start actions through the shared lifecycle decision while preserving suspended read-only access to existing review, evidence, and generated-pack history - add focused Pest coverage plus the spec/plan/tasks/data-model/contract artifacts for the feature ## Validation - targeted Pest unit and feature lanes for lifecycle resolution, system-plane mutation, onboarding gating, review-pack enforcement, download preservation, customer review workspace access, and evidence snapshot access - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - integrated browser smoke on the system workspace detail and the preserved read-only review/evidence/review-pack surfaces ## Notes - branch: `251-commercial-entitlements-billing-state` - base: `dev` - commit: `606e9760` Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/292 --- .github/agents/copilot-instructions.md | 4 +- .../Filament/Pages/Reviews/ReviewRegister.php | 20 +- .../ManagedTenantOnboardingWizard.php | 34 +- .../Filament/Resources/ReviewPackResource.php | 16 +- .../Resources/TenantReviewResource.php | 16 +- .../System/Pages/Directory/ViewWorkspace.php | 77 +++ .../Widgets/Tenant/TenantReviewPackCard.php | 14 + .../WorkspaceCommercialLifecycleResolver.php | 410 +++++++++++++++ .../app/Services/ReviewPackService.php | 20 +- .../app/Services/Settings/SettingsWriter.php | 135 ++++- .../app/Support/Auth/PlatformCapabilities.php | 2 + .../app/Support/Badges/BadgeCatalog.php | 1 + .../app/Support/Badges/BadgeDomain.php | 1 + .../Domains/CommercialLifecycleStateBadge.php | 26 + .../app/Support/Settings/SettingsRegistry.php | 39 ++ .../ActionSurface/ActionSurfaceExemptions.php | 9 +- .../Support/Workspaces/WorkspaceResolver.php | 35 ++ .../pages/directory/view-workspace.blade.php | 66 +++ .../tenant/tenant-review-pack-card.blade.php | 7 + .../Evidence/EvidenceSnapshotResourceTest.php | 51 ++ ...ManagedTenantOnboardingEntitlementTest.php | 90 +++- .../ReviewPack/ReviewPackDownloadTest.php | 36 ++ .../ReviewPackEntitlementEnforcementTest.php | 106 +++- .../ReviewPack/ReviewPackGenerationTest.php | 38 ++ .../CustomerReviewWorkspacePackAccessTest.php | 61 ++- .../Spec113/AuthorizationSemanticsTest.php | 37 ++ .../System/ViewWorkspaceEntitlementsTest.php | 111 ++++- .../CommercialLifecycleStateBadgeTest.php | 20 + ...rkspaceCommercialLifecycleResolverTest.php | 199 ++++++++ .../Workspaces/WorkspaceResolverTest.php | 52 ++ get_gitea_tools.py | 50 ++ .../checklists/requirements.md | 42 ++ ...ial-lifecycle-overlay.logical.openapi.yaml | 465 ++++++++++++++++++ .../data-model.md | 170 +++++++ .../plan.md | 297 +++++++++++ .../quickstart.md | 109 ++++ .../research.md | 84 ++++ .../spec.md | 332 +++++++++++++ .../tasks.md | 190 +++++++ 39 files changed, 3418 insertions(+), 54 deletions(-) create mode 100644 apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php create mode 100644 apps/platform/app/Support/Badges/Domains/CommercialLifecycleStateBadge.php create mode 100644 apps/platform/tests/Unit/Badges/CommercialLifecycleStateBadgeTest.php create mode 100644 apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php create mode 100644 apps/platform/tests/Unit/Support/Workspaces/WorkspaceResolverTest.php create mode 100644 get_gitea_tools.py create mode 100644 specs/251-commercial-entitlements-billing-state/checklists/requirements.md create mode 100644 specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml create mode 100644 specs/251-commercial-entitlements-billing-state/data-model.md create mode 100644 specs/251-commercial-entitlements-billing-state/plan.md create mode 100644 specs/251-commercial-entitlements-billing-state/quickstart.md create mode 100644 specs/251-commercial-entitlements-billing-state/research.md create mode 100644 specs/251-commercial-entitlements-billing-state/spec.md create mode 100644 specs/251-commercial-entitlements-billing-state/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 7b699aba..80499eb9 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -262,6 +262,8 @@ ## Active Technologies - PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack) - PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services (249-customer-review-workspace) - PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace) +- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page (251-commercial-entitlements-billing-state) +- PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model (251-commercial-entitlements-billing-state) - PHP 8.4.15 (feat/005-bulk-operations) @@ -296,9 +298,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 251-commercial-entitlements-billing-state: Added PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page - 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services - 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` -- 240-tenant-onboarding-readiness: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers ### Pre-production compatibility check diff --git a/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php b/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php index 79b3831c..ef641a46 100644 --- a/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php +++ b/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php @@ -178,9 +178,23 @@ public function table(Table $table): Table && auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant) && in_array($record->status, ['ready', 'published'], true)) ->disabled(fn (TenantReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false)) - ->tooltip(fn (TenantReview $record): ?string => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false) - ? (string) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['block_reason'] ?? '') - : null) + ->tooltip(function (TenantReview $record): ?string { + $decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant); + + if ((bool) ($decision['is_blocked'] ?? false)) { + $reason = $decision['block_reason'] ?? null; + + return is_string($reason) && $reason !== '' ? $reason : null; + } + + if ((bool) ($decision['is_warning'] ?? false)) { + $reason = $decision['warning_reason'] ?? null; + + return is_string($reason) && $reason !== '' ? $reason : null; + } + + return null; + }) ->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)), ]) ->bulkActions([]) diff --git a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index d95e2623..3127b1ff 100644 --- a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -30,6 +30,7 @@ use App\Services\Onboarding\OnboardingDraftResolver; use App\Services\Onboarding\OnboardingDraftStageResolver; use App\Services\Onboarding\OnboardingLifecycleService; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\OperationRunService; use App\Services\Providers\ProviderConnectionMutationService; @@ -4551,27 +4552,30 @@ private function completionSummaryEntitlementDecision(): array return []; } - return app(WorkspaceEntitlementResolver::class)->resolve( + return app(WorkspaceCommercialLifecycleResolver::class)->actionDecision( $this->workspace, - WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT, + WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION, ); } private function completionSummaryEntitlementBlocked(): bool { - return (bool) ($this->completionSummaryEntitlementDecision()['is_blocked'] ?? false); + return ($this->completionSummaryEntitlementDecision()['outcome'] ?? null) === WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK; } private function completionSummaryEntitlementSummary(): string { $decision = $this->completionSummaryEntitlementDecision(); - $currentUsage = (int) ($decision['current_usage'] ?? 0); - $effectiveValue = (int) ($decision['effective_value'] ?? 0); - $sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision); + $entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] : []; + $currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0); + $effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0); + $sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision); + $stateLabel = is_string($decision['state_label'] ?? null) ? $decision['state_label'] : 'Active paid'; return sprintf( - '%s - %d active of %d allowed (%s)', + '%s - %s - %d active of %d allowed (%s)', $this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed', + $stateLabel, $currentUsage, $effectiveValue, $sourceLabel, @@ -4581,13 +4585,15 @@ private function completionSummaryEntitlementSummary(): string private function completionSummaryEntitlementDetail(): string { $decision = $this->completionSummaryEntitlementDecision(); - $currentUsage = (int) ($decision['current_usage'] ?? 0); - $effectiveValue = (int) ($decision['effective_value'] ?? 0); - $remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0); - $sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision); + $entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] : []; + $currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0); + $effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0); + $remainingCapacity = (int) ($entitlementDecision['remaining_capacity'] ?? 0); + $sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision); $rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null; $message = sprintf( - 'Current usage is %d active managed tenant%s out of %d allowed. Source: %s.', + '%s Current usage is %d active managed tenant%s out of %d allowed. Source: %s.', + (string) ($decision['message'] ?? 'Managed-tenant activation is available for this workspace commercial state.'), $currentUsage, $currentUsage === 1 ? '' : 's', $effectiveValue, @@ -4606,7 +4612,7 @@ private function completionSummaryEntitlementDetail(): string } } - if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === 'workspace_override') { + if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING) { $message .= ' Rationale: '.$rationale; } @@ -4982,7 +4988,7 @@ public function completeOnboarding(): void if ($this->completionSummaryEntitlementBlocked()) { Notification::make() - ->title('Activation limit reached') + ->title('Activation unavailable') ->body($this->completionSummaryEntitlementDetail()) ->warning() ->send(); diff --git a/apps/platform/app/Filament/Resources/ReviewPackResource.php b/apps/platform/app/Filament/Resources/ReviewPackResource.php index 86e26d67..68fd4537 100644 --- a/apps/platform/app/Filament/Resources/ReviewPackResource.php +++ b/apps/platform/app/Filament/Resources/ReviewPackResource.php @@ -575,6 +575,19 @@ public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): return is_string($reason) && $reason !== '' ? $reason : null; } + public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string + { + $decision = static::reviewPackGenerationDecision($tenant); + + if (! (bool) ($decision['is_warning'] ?? false)) { + return null; + } + + $reason = $decision['warning_reason'] ?? null; + + return is_string($reason) && $reason !== '' ? $reason : null; + } + public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string { $tenant ??= static::currentTenantContext(); @@ -584,6 +597,7 @@ public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null) return AuthUiTooltips::insufficientPermission(); } - return static::reviewPackGenerationBlockReason($tenant); + return static::reviewPackGenerationBlockReason($tenant) + ?? static::reviewPackGenerationWarningReason($tenant); } } diff --git a/apps/platform/app/Filament/Resources/TenantReviewResource.php b/apps/platform/app/Filament/Resources/TenantReviewResource.php index 8ae2220e..cc66e41b 100644 --- a/apps/platform/app/Filament/Resources/TenantReviewResource.php +++ b/apps/platform/app/Filament/Resources/TenantReviewResource.php @@ -464,6 +464,19 @@ public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): return is_string($reason) && $reason !== '' ? $reason : null; } + public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string + { + $decision = static::reviewPackGenerationDecision($tenant); + + if (! (bool) ($decision['is_warning'] ?? false)) { + return null; + } + + $reason = $decision['warning_reason'] ?? null; + + return is_string($reason) && $reason !== '' ? $reason : null; + } + public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string { $tenant ??= static::panelTenantContext(); @@ -473,7 +486,8 @@ public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null) return AuthUiTooltips::insufficientPermission(); } - return static::reviewPackGenerationBlockReason($tenant); + return static::reviewPackGenerationBlockReason($tenant) + ?? static::reviewPackGenerationWarningReason($tenant); } public static function executeExport(TenantReview $review): void diff --git a/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php b/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php index 1ba62715..38218989 100644 --- a/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php +++ b/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php @@ -9,13 +9,19 @@ use App\Models\PlatformUser; use App\Models\Tenant; use App\Models\Workspace; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver; +use App\Services\Settings\SettingsWriter; use App\Support\Auth\PlatformCapabilities; use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery; use App\Support\OperationCatalog; use App\Support\System\SystemDirectoryLinks; use App\Support\System\SystemOperationRunLinks; use App\Support\SystemConsole\SystemConsoleWindow; +use Filament\Actions\Action; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; +use Filament\Notifications\Notification; use Filament\Pages\Page; use Illuminate\Support\Collection; @@ -94,6 +100,77 @@ public function workspaceEntitlementSummary(): array return app(WorkspaceEntitlementResolver::class)->summary($this->workspace); } + /** + * @return array + */ + public function workspaceCommercialLifecycleSummary(): array + { + return app(WorkspaceCommercialLifecycleResolver::class)->summary($this->workspace); + } + + /** + * @return array + */ + protected function getHeaderActions(): array + { + return [ + Action::make('change_commercial_state') + ->label('Change commercial state') + ->icon('heroicon-o-adjustments-horizontal') + ->color('warning') + ->visible(fn (): bool => $this->canManageCommercialLifecycle()) + ->requiresConfirmation() + ->modalHeading('Change commercial state') + ->modalDescription('This changes the workspace commercial lifecycle overlay. The rationale is audited and affects onboarding activation and review-pack starts.') + ->form([ + Select::make('state') + ->label('Commercial state') + ->options(WorkspaceCommercialLifecycleResolver::stateLabels()) + ->required() + ->default(fn (): string => (string) ($this->workspaceCommercialLifecycleSummary()['state'] ?? WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID)), + Textarea::make('reason') + ->label('Rationale') + ->required() + ->minLength(5) + ->maxLength(500) + ->rows(4), + ]) + ->action(function (array $data, SettingsWriter $settingsWriter): void { + $actor = auth('platform')->user(); + + if (! $actor instanceof PlatformUser) { + abort(403); + } + + if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) { + abort(403); + } + + $settingsWriter->updateWorkspaceCommercialLifecycle( + actor: $actor, + workspace: $this->workspace, + state: (string) ($data['state'] ?? ''), + reason: (string) ($data['reason'] ?? ''), + ); + + $this->workspace->refresh(); + + Notification::make() + ->title('Commercial state updated') + ->success() + ->send(); + }), + ]; + } + + private function canManageCommercialLifecycle(): bool + { + $user = auth('platform')->user(); + + return $user instanceof PlatformUser + && $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE); + } + /** * @return array{ * overall: array{label: string, color: string, icon: string|null}, diff --git a/apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php b/apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php index 5052d05b..9eb73e28 100644 --- a/apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php +++ b/apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php @@ -81,6 +81,14 @@ public function generatePack(bool $includePii = true, bool $includeOperations = return; } + if ((bool) ($decision['is_warning'] ?? false) && is_string($decision['warning_reason'] ?? null)) { + Notification::make() + ->title('Review pack generation allowed with warning') + ->body($decision['warning_reason']) + ->warning() + ->send(); + } + $activeRun = $service->checkActiveRun($tenant) ? OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) @@ -163,6 +171,9 @@ protected function getViewData(): array $generationBlockReason = is_string($generationEntitlement['block_reason'] ?? null) ? $generationEntitlement['block_reason'] : null; + $generationWarningReason = is_string($generationEntitlement['warning_reason'] ?? null) + ? $generationEntitlement['warning_reason'] + : null; $latestPack = ReviewPack::query() ->with(['tenantReview', 'operationRun']) @@ -181,6 +192,7 @@ protected function getViewData(): array 'canManage' => $canManage, 'generationBlocked' => $generationBlocked, 'generationBlockReason' => $generationBlockReason, + 'generationWarningReason' => $generationWarningReason, 'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null, 'downloadUrl' => null, 'failedReason' => null, @@ -232,6 +244,7 @@ protected function getViewData(): array 'canManage' => $canManage, 'generationBlocked' => $generationBlocked, 'generationBlockReason' => $generationBlockReason, + 'generationWarningReason' => $generationWarningReason, 'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null, 'downloadUrl' => $downloadUrl, 'failedReason' => $failedReason, @@ -265,6 +278,7 @@ private function emptyState(): array 'canManage' => false, 'generationBlocked' => false, 'generationBlockReason' => null, + 'generationWarningReason' => null, 'customerWorkspaceUrl' => null, 'downloadUrl' => null, 'failedReason' => null, diff --git a/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php b/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php new file mode 100644 index 00000000..cf47cd8c --- /dev/null +++ b/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php @@ -0,0 +1,410 @@ + + */ + public static function stateIds(): array + { + return [ + self::STATE_TRIAL, + self::STATE_GRACE, + self::STATE_ACTIVE_PAID, + self::STATE_SUSPENDED_READ_ONLY, + ]; + } + + /** + * @return array + */ + public static function stateLabels(): array + { + return [ + self::STATE_TRIAL => 'Trial', + self::STATE_GRACE => 'Grace', + self::STATE_ACTIVE_PAID => 'Active paid', + self::STATE_SUSPENDED_READ_ONLY => 'Suspended / read-only', + ]; + } + + /** + * @return array + */ + public static function stateDescriptions(): array + { + return [ + self::STATE_TRIAL => 'Expansion and review-pack starts are available while the workspace is in trial.', + self::STATE_GRACE => 'New managed-tenant activation is frozen, but review-pack starts remain available with a warning.', + self::STATE_ACTIVE_PAID => 'Expansion and review-pack starts are available for the active paid workspace.', + self::STATE_SUSPENDED_READ_ONLY => 'New activation and review-pack starts are blocked while existing review, evidence, and generated-pack access remains read-only.', + ]; + } + + /** + * @return array + */ + public function summary(Workspace $workspace): array + { + $lifecycle = $this->resolve($workspace); + + return $lifecycle + [ + 'entitlement_summary' => $this->workspaceEntitlementResolver->summary($workspace), + 'action_decisions' => [ + self::ACTION_MANAGED_TENANT_ACTIVATION => $this->actionDecision($workspace, self::ACTION_MANAGED_TENANT_ACTIVATION, $lifecycle), + self::ACTION_REVIEW_PACK_START => $this->actionDecision($workspace, self::ACTION_REVIEW_PACK_START, $lifecycle), + self::ACTION_REVIEW_HISTORY_READ => $this->actionDecision($workspace, self::ACTION_REVIEW_HISTORY_READ, $lifecycle), + self::ACTION_EVIDENCE_READ => $this->actionDecision($workspace, self::ACTION_EVIDENCE_READ, $lifecycle), + self::ACTION_GENERATED_PACK_READ => $this->actionDecision($workspace, self::ACTION_GENERATED_PACK_READ, $lifecycle), + ], + ]; + } + + /** + * @return array{ + * workspace_id: int, + * state: string, + * state_label: string, + * source: string, + * source_label: string, + * rationale: string|null, + * description: string, + * last_changed_at: CarbonInterface|null, + * last_changed_by: string|null + * } + */ + public function resolve(Workspace $workspace): array + { + $stateSetting = $this->settingsResolver->resolveDetailed( + workspace: $workspace, + domain: self::SETTING_DOMAIN, + key: self::SETTING_COMMERCIAL_LIFECYCLE_STATE, + ); + + $rawState = is_string($stateSetting['value'] ?? null) + ? strtolower(trim((string) $stateSetting['value'])) + : null; + + $state = in_array($rawState, self::stateIds(), true) + ? $rawState + : self::STATE_ACTIVE_PAID; + + $source = ($stateSetting['source'] ?? null) === 'workspace_override' && $rawState !== null + ? self::SOURCE_WORKSPACE_SETTING + : self::SOURCE_DEFAULT_ACTIVE_PAID; + + $rationale = $this->settingsResolver->resolveValue( + workspace: $workspace, + domain: self::SETTING_DOMAIN, + key: self::SETTING_COMMERCIAL_LIFECYCLE_REASON, + ); + + $labels = self::stateLabels(); + $descriptions = self::stateDescriptions(); + $lastChanged = $this->lastChangedMetadata($workspace); + + return [ + 'workspace_id' => (int) $workspace->getKey(), + 'state' => $state, + 'state_label' => $labels[$state], + 'source' => $source, + 'source_label' => $source === self::SOURCE_WORKSPACE_SETTING + ? 'workspace setting' + : 'default active paid', + 'rationale' => is_string($rationale) && trim($rationale) !== '' ? trim($rationale) : null, + 'description' => $descriptions[$state], + 'last_changed_at' => $lastChanged['last_changed_at'], + 'last_changed_by' => $lastChanged['last_changed_by'], + ]; + } + + /** + * @param array|null $lifecycle + * @return array + */ + public function actionDecision(Workspace $workspace, string $actionKey, ?array $lifecycle = null): array + { + $lifecycle ??= $this->resolve($workspace); + + return match ($actionKey) { + self::ACTION_MANAGED_TENANT_ACTIVATION => $this->managedTenantActivationDecision($workspace, $lifecycle), + self::ACTION_REVIEW_PACK_START => $this->reviewPackStartDecision($workspace, $lifecycle), + self::ACTION_REVIEW_HISTORY_READ, + self::ACTION_EVIDENCE_READ, + self::ACTION_GENERATED_PACK_READ => $this->readOnlyDecision($actionKey, $lifecycle), + default => throw new \InvalidArgumentException(sprintf('Unknown commercial lifecycle action key: %s', $actionKey)), + }; + } + + /** + * @return array + */ + public function reviewPackStartDecisionForTenant(Tenant $tenant): array + { + $tenant->loadMissing('workspace'); + + return $this->actionDecision($tenant->workspace, self::ACTION_REVIEW_PACK_START); + } + + /** + * @param array $lifecycle + * @return array + */ + private function managedTenantActivationDecision(Workspace $workspace, array $lifecycle): array + { + $substrateDecision = $this->workspaceEntitlementResolver->resolve( + $workspace, + WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT, + ); + + if ((bool) ($substrateDecision['is_blocked'] ?? false)) { + return $this->decision( + lifecycle: $lifecycle, + actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION, + outcome: self::OUTCOME_BLOCK, + reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE, + message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks managed tenant activation.'), + substrateDecision: $substrateDecision, + ); + } + + return match ($lifecycle['state']) { + self::STATE_GRACE => $this->decision( + lifecycle: $lifecycle, + actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION, + outcome: self::OUTCOME_BLOCK, + reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + message: 'New managed-tenant activation is frozen while this workspace is in grace.', + substrateDecision: $substrateDecision, + ), + self::STATE_SUSPENDED_READ_ONLY => $this->decision( + lifecycle: $lifecycle, + actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION, + outcome: self::OUTCOME_BLOCK, + reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + message: 'This workspace is suspended / read-only. New managed-tenant activation is blocked, but existing review and evidence history remains available.', + substrateDecision: $substrateDecision, + ), + default => $this->decision( + lifecycle: $lifecycle, + actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION, + outcome: self::OUTCOME_ALLOW, + reasonFamily: null, + message: 'Managed-tenant activation is available for this workspace commercial state.', + substrateDecision: $substrateDecision, + ), + }; + } + + /** + * @param array $lifecycle + * @return array + */ + private function reviewPackStartDecision(Workspace $workspace, array $lifecycle): array + { + $substrateDecision = $this->workspaceEntitlementResolver->resolve( + $workspace, + WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED, + ); + + if ((bool) ($substrateDecision['is_blocked'] ?? false)) { + return $this->decision( + lifecycle: $lifecycle, + actionKey: self::ACTION_REVIEW_PACK_START, + outcome: self::OUTCOME_BLOCK, + reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE, + message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'), + substrateDecision: $substrateDecision, + ); + } + + return match ($lifecycle['state']) { + self::STATE_GRACE => $this->decision( + lifecycle: $lifecycle, + actionKey: self::ACTION_REVIEW_PACK_START, + outcome: self::OUTCOME_WARN, + reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + message: 'Workspace is in grace. Review-pack starts remain available, but managed-tenant expansion is frozen.', + substrateDecision: $substrateDecision, + ), + self::STATE_SUSPENDED_READ_ONLY => $this->decision( + lifecycle: $lifecycle, + actionKey: self::ACTION_REVIEW_PACK_START, + outcome: self::OUTCOME_BLOCK, + reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + message: 'This workspace is suspended / read-only. New review-pack starts are blocked, but existing review packs, evidence, and review history remain available.', + substrateDecision: $substrateDecision, + ), + default => $this->decision( + lifecycle: $lifecycle, + actionKey: self::ACTION_REVIEW_PACK_START, + outcome: self::OUTCOME_ALLOW, + reasonFamily: null, + message: 'Review-pack starts are available for this workspace commercial state.', + substrateDecision: $substrateDecision, + ), + }; + } + + /** + * @param array $lifecycle + * @return array + */ + private function readOnlyDecision(string $actionKey, array $lifecycle): array + { + if (($lifecycle['state'] ?? null) === self::STATE_SUSPENDED_READ_ONLY) { + return $this->decision( + lifecycle: $lifecycle, + actionKey: $actionKey, + outcome: self::OUTCOME_ALLOW_READ_ONLY, + reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + message: 'Suspended / read-only workspaces keep existing review, evidence, and generated-pack consumption available under current RBAC.', + substrateDecision: null, + ); + } + + return $this->decision( + lifecycle: $lifecycle, + actionKey: $actionKey, + outcome: self::OUTCOME_ALLOW, + reasonFamily: null, + message: 'Read-only history remains available under current RBAC.', + substrateDecision: null, + ); + } + + /** + * @param array $lifecycle + * @param array|null $substrateDecision + * @return array + */ + private function decision( + array $lifecycle, + string $actionKey, + string $outcome, + ?string $reasonFamily, + string $message, + ?array $substrateDecision, + ): array { + return [ + 'workspace_id' => (int) ($lifecycle['workspace_id'] ?? 0), + 'action_key' => $actionKey, + 'outcome' => $outcome, + 'is_blocked' => $outcome === self::OUTCOME_BLOCK, + 'is_warning' => $outcome === self::OUTCOME_WARN, + 'block_reason' => $outcome === self::OUTCOME_BLOCK ? $message : null, + 'warning_reason' => $outcome === self::OUTCOME_WARN ? $message : null, + 'message' => $message, + 'reason_family' => $reasonFamily, + 'state' => (string) $lifecycle['state'], + 'state_label' => (string) $lifecycle['state_label'], + 'source' => (string) $lifecycle['source'], + 'source_label' => (string) $lifecycle['source_label'], + 'rationale' => $lifecycle['rationale'] ?? null, + 'entitlement_decision' => $substrateDecision, + ]; + } + + /** + * @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} + */ + private function lastChangedMetadata(Workspace $workspace): array + { + $audit = AuditLog::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('action', AuditActionId::WorkspaceSettingUpdated->value) + ->where('resource_type', 'workspace_setting') + ->where('resource_id', self::SETTING_DOMAIN.'.'.self::SETTING_COMMERCIAL_LIFECYCLE_STATE) + ->latest('recorded_at') + ->latest('id') + ->first(); + + if ($audit instanceof AuditLog) { + return [ + 'last_changed_at' => $audit->recorded_at, + 'last_changed_by' => $audit->actorDisplayLabel(), + ]; + } + + $record = WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', self::SETTING_DOMAIN) + ->whereIn('key', [ + self::SETTING_COMMERCIAL_LIFECYCLE_STATE, + self::SETTING_COMMERCIAL_LIFECYCLE_REASON, + ]) + ->with('updatedByUser:id,name') + ->latest('updated_at') + ->latest('id') + ->first(); + + if (! $record instanceof WorkspaceSetting) { + return [ + 'last_changed_at' => null, + 'last_changed_by' => null, + ]; + } + + return [ + 'last_changed_at' => $record->updated_at, + 'last_changed_by' => $record->updatedByUser?->name, + ]; + } +} diff --git a/apps/platform/app/Services/ReviewPackService.php b/apps/platform/app/Services/ReviewPackService.php index 2ca0e04b..69fb9357 100644 --- a/apps/platform/app/Services/ReviewPackService.php +++ b/apps/platform/app/Services/ReviewPackService.php @@ -14,7 +14,7 @@ use App\Models\TenantReview; use App\Models\User; use App\Services\Audit\WorkspaceAuditLogger; -use App\Services\Entitlements\WorkspaceEntitlementResolver; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Evidence\EvidenceResolutionRequest; use App\Services\Evidence\EvidenceSnapshotResolver; use App\Support\Audit\AuditActionId; @@ -30,7 +30,7 @@ public function __construct( private OperationRunService $operationRunService, private EvidenceSnapshotResolver $snapshotResolver, private WorkspaceAuditLogger $auditLogger, - private WorkspaceEntitlementResolver $workspaceEntitlementResolver, + private WorkspaceCommercialLifecycleResolver $workspaceCommercialLifecycleResolver, private ProductTelemetryRecorder $productTelemetryRecorder, ) {} @@ -253,10 +253,22 @@ public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): s */ public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array { - return $this->workspaceEntitlementResolver->resolve( + $tenant->loadMissing('workspace'); + $decision = $this->workspaceCommercialLifecycleResolver->actionDecision( $tenant->workspace, - WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED, + WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START, ); + + $entitlementDecision = is_array($decision['entitlement_decision'] ?? null) + ? $decision['entitlement_decision'] + : []; + + return $decision + [ + 'effective_value' => $entitlementDecision['effective_value'] ?? null, + 'source' => $decision['source'] ?? null, + 'current_usage' => $entitlementDecision['current_usage'] ?? null, + 'remaining_capacity' => $entitlementDecision['remaining_capacity'] ?? null, + ]; } private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void diff --git a/apps/platform/app/Services/Settings/SettingsWriter.php b/apps/platform/app/Services/Settings/SettingsWriter.php index f37a35d6..ca2b1e30 100644 --- a/apps/platform/app/Services/Settings/SettingsWriter.php +++ b/apps/platform/app/Services/Settings/SettingsWriter.php @@ -4,6 +4,7 @@ namespace App\Services\Settings; +use App\Models\PlatformUser; use App\Models\Tenant; use App\Models\TenantSetting; use App\Models\User; @@ -11,11 +12,14 @@ use App\Models\WorkspaceSetting; use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Auth\WorkspaceCapabilityResolver; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Support\Audit\AuditActionId; use App\Support\Auth\Capabilities; +use App\Support\Auth\PlatformCapabilities; use App\Support\Settings\SettingDefinition; use App\Support\Settings\SettingsRegistry; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -33,27 +37,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string { $this->authorizeManage($actor, $workspace); - $definition = $this->requireDefinition($domain, $key); - $normalizedValue = $this->validatedValue($definition, $value); - - $existing = WorkspaceSetting::query() - ->where('workspace_id', (int) $workspace->getKey()) - ->where('domain', $domain) - ->where('key', $key) - ->first(); - - $beforeValue = $existing instanceof WorkspaceSetting - ? $this->decodeStoredValue($existing->getAttribute('value')) - : null; - - $setting = WorkspaceSetting::query()->updateOrCreate([ - 'workspace_id' => (int) $workspace->getKey(), - 'domain' => $domain, - 'key' => $key, - ], [ - 'value' => $normalizedValue, - 'updated_by_user_id' => (int) $actor->getKey(), - ]); + $result = $this->persistWorkspaceSetting($workspace, $domain, $key, $value, (int) $actor->getKey()); $this->resolver->clearCache(); @@ -67,7 +51,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string 'scope' => 'workspace', 'domain' => $domain, 'key' => $key, - 'before_value' => $beforeValue, + 'before_value' => $result['before_value'], 'after_value' => $afterValue, ], ], @@ -76,7 +60,79 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string resourceId: $domain.'.'.$key, ); - return $setting; + return $result['setting']; + } + + public function updateWorkspaceCommercialLifecycle( + PlatformUser $actor, + Workspace $workspace, + string $state, + string $reason, + ): void { + $state = strtolower(trim($state)); + $reason = trim($reason); + + if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) { + throw new AuthorizationException('Missing commercial lifecycle manage capability.'); + } + + if ($reason === '') { + throw ValidationException::withMessages([ + 'reason' => ['A rationale is required when changing commercial lifecycle state.'], + ]); + } + + DB::transaction(function () use ($actor, $workspace, $state, $reason): void { + $stateResult = $this->persistWorkspaceSetting( + workspace: $workspace, + domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN, + key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE, + value: $state, + updatedByUserId: null, + ); + + $reasonResult = $this->persistWorkspaceSetting( + workspace: $workspace, + domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN, + key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON, + value: $reason, + updatedByUserId: null, + ); + + $this->resolver->clearCache(); + + $afterState = $this->resolver->resolveValue( + $workspace, + WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN, + WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE, + ); + + $afterReason = $this->resolver->resolveValue( + $workspace, + WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN, + WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON, + ); + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::WorkspaceSettingUpdated->value, + context: [ + 'metadata' => [ + 'scope' => 'workspace', + 'domain' => WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN, + 'key' => WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE, + 'before_state' => $stateResult['before_value'], + 'after_state' => $afterState, + 'before_reason' => $reasonResult['before_value'], + 'after_reason' => $afterReason, + ], + ], + actor: $actor, + resourceType: 'workspace_setting', + resourceId: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN.'.'.WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE, + targetLabel: 'Commercial lifecycle state', + ); + }); } public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void @@ -174,6 +230,39 @@ private function requireDefinition(string $domain, string $key): SettingDefiniti ]); } + /** + * @return array{setting: WorkspaceSetting, before_value: mixed} + */ + private function persistWorkspaceSetting(Workspace $workspace, string $domain, string $key, mixed $value, ?int $updatedByUserId): array + { + $definition = $this->requireDefinition($domain, $key); + $normalizedValue = $this->validatedValue($definition, $value); + + $existing = WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', $domain) + ->where('key', $key) + ->first(); + + $beforeValue = $existing instanceof WorkspaceSetting + ? $this->decodeStoredValue($existing->getAttribute('value')) + : null; + + $setting = WorkspaceSetting::query()->updateOrCreate([ + 'workspace_id' => (int) $workspace->getKey(), + 'domain' => $domain, + 'key' => $key, + ], [ + 'value' => $normalizedValue, + 'updated_by_user_id' => $updatedByUserId, + ]); + + return [ + 'setting' => $setting, + 'before_value' => $beforeValue, + ]; + } + private function validatedValue(SettingDefinition $definition, mixed $value): mixed { $validator = Validator::make( diff --git a/apps/platform/app/Support/Auth/PlatformCapabilities.php b/apps/platform/app/Support/Auth/PlatformCapabilities.php index e1575640..7f3e359e 100644 --- a/apps/platform/app/Support/Auth/PlatformCapabilities.php +++ b/apps/platform/app/Support/Auth/PlatformCapabilities.php @@ -18,6 +18,8 @@ class PlatformCapabilities public const DIRECTORY_VIEW = 'platform.directory.view'; + public const COMMERCIAL_LIFECYCLE_MANAGE = 'platform.commercial_lifecycle.manage'; + public const OPERATIONS_VIEW = 'platform.operations.view'; public const OPERATIONS_MANAGE = 'platform.operations.manage'; diff --git a/apps/platform/app/Support/Badges/BadgeCatalog.php b/apps/platform/app/Support/Badges/BadgeCatalog.php index 3c4daf1d..daf3b3b0 100644 --- a/apps/platform/app/Support/Badges/BadgeCatalog.php +++ b/apps/platform/app/Support/Badges/BadgeCatalog.php @@ -57,6 +57,7 @@ final class BadgeCatalog BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class, BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class, BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class, + BadgeDomain::CommercialLifecycleState->value => Domains\CommercialLifecycleStateBadge::class, BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class, BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class, BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class, diff --git a/apps/platform/app/Support/Badges/BadgeDomain.php b/apps/platform/app/Support/Badges/BadgeDomain.php index 69a0f4b1..ab865692 100644 --- a/apps/platform/app/Support/Badges/BadgeDomain.php +++ b/apps/platform/app/Support/Badges/BadgeDomain.php @@ -48,6 +48,7 @@ enum BadgeDomain: string case BaselineProfileStatus = 'baseline_profile_status'; case FindingType = 'finding_type'; case ReviewPackStatus = 'review_pack_status'; + case CommercialLifecycleState = 'commercial_lifecycle_state'; case EvidenceSnapshotStatus = 'evidence_snapshot_status'; case EvidenceCompleteness = 'evidence_completeness'; case TenantReviewStatus = 'tenant_review_status'; diff --git a/apps/platform/app/Support/Badges/Domains/CommercialLifecycleStateBadge.php b/apps/platform/app/Support/Badges/Domains/CommercialLifecycleStateBadge.php new file mode 100644 index 00000000..e9545e98 --- /dev/null +++ b/apps/platform/app/Support/Badges/Domains/CommercialLifecycleStateBadge.php @@ -0,0 +1,26 @@ + new BadgeSpec('Trial', 'info', 'heroicon-m-clock'), + WorkspaceCommercialLifecycleResolver::STATE_GRACE => new BadgeSpec('Grace', 'warning', 'heroicon-m-exclamation-triangle'), + WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID => new BadgeSpec('Active paid', 'success', 'heroicon-m-check-circle'), + WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY => new BadgeSpec('Suspended / read-only', 'danger', 'heroicon-m-lock-closed'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/apps/platform/app/Support/Settings/SettingsRegistry.php b/apps/platform/app/Support/Settings/SettingsRegistry.php index 694a930e..48c352b6 100644 --- a/apps/platform/app/Support/Settings/SettingsRegistry.php +++ b/apps/platform/app/Support/Settings/SettingsRegistry.php @@ -6,6 +6,7 @@ use App\Support\Ai\AiPolicyMode; use App\Models\Finding; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Entitlements\WorkspacePlanProfileCatalog; final class SettingsRegistry @@ -314,6 +315,44 @@ static function (string $attribute, mixed $value, \Closure $fail): void { return $normalized === '' ? null : $normalized; }, )); + + $this->register(new SettingDefinition( + domain: 'entitlements', + key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE, + type: 'string', + systemDefault: null, + rules: [ + 'nullable', + 'string', + 'in:'.implode(',', WorkspaceCommercialLifecycleResolver::stateIds()), + ], + normalizer: static function (mixed $value): ?string { + if ($value === null) { + return null; + } + + $normalized = strtolower(trim((string) $value)); + + return $normalized === '' ? null : $normalized; + }, + )); + + $this->register(new SettingDefinition( + domain: 'entitlements', + key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON, + type: 'string', + systemDefault: null, + rules: ['nullable', 'string', 'max:500'], + normalizer: static function (mixed $value): ?string { + if ($value === null) { + return null; + } + + $normalized = trim((string) $value); + + return $normalized === '' ? null : $normalized; + }, + )); } /** diff --git a/apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php b/apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php index 1bf5193d..97c86236 100644 --- a/apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php +++ b/apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php @@ -749,12 +749,17 @@ public static function spec195ResidualSurfaceInventory(): array 'discoveryState' => 'outside_primary_discovery', 'closureDecision' => 'harmless_special_case', 'reasonCategory' => 'read_mostly_context_detail', - 'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown that exposes context and links, not a declaration-backed mutable system workbench.', + 'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown with one bounded, capability-gated commercial lifecycle mutation added by spec 251; it is still not a declaration-backed mutable system workbench.', 'evidence' => [ [ 'kind' => 'feature_livewire_test', 'reference' => 'tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php', - 'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links without mutating actions.', + 'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links while remaining outside the primary declaration-backed table contract.', + ], + [ + 'kind' => 'feature_livewire_test', + 'reference' => 'tests/Feature/System/ViewWorkspaceEntitlementsTest.php', + 'proves' => 'The commercial lifecycle mutation is separately capability-gated, confirmation-protected, rationale-required, and audited.', ], [ 'kind' => 'authorization_test', diff --git a/apps/platform/app/Support/Workspaces/WorkspaceResolver.php b/apps/platform/app/Support/Workspaces/WorkspaceResolver.php index 5535db85..92b1108b 100644 --- a/apps/platform/app/Support/Workspaces/WorkspaceResolver.php +++ b/apps/platform/app/Support/Workspaces/WorkspaceResolver.php @@ -8,6 +8,8 @@ final class WorkspaceResolver { public function resolve(string $value): ?Workspace { + $value = $this->normalizeRouteValue($value); + $workspace = Workspace::query() ->where('slug', $value) ->first(); @@ -22,4 +24,37 @@ public function resolve(string $value): ?Workspace return Workspace::query()->whereKey((int) $value)->first(); } + + private function normalizeRouteValue(string $value): string + { + $value = trim($value); + + if (! str_starts_with($value, '{')) { + return $value; + } + + $decoded = json_decode($value, true); + + if (! is_array($decoded)) { + return $value; + } + + $slug = $decoded['slug'] ?? null; + + if (is_string($slug) && $slug !== '') { + return $slug; + } + + $id = $decoded['id'] ?? null; + + if (is_int($id)) { + return (string) $id; + } + + if (is_string($id) && ctype_digit($id)) { + return $id; + } + + return $value; + } } diff --git a/apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php b/apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php index 7f3baa8e..36891f0b 100644 --- a/apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php +++ b/apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php @@ -1,9 +1,18 @@ @php + use App\Support\Badges\BadgeCatalog; + use App\Support\Badges\BadgeDomain; + /** @var \App\Models\Workspace $workspace */ $workspace = $this->workspace; $customerHealthDecision = $this->customerHealthDecision(); $tenants = $this->workspaceTenants(); $runs = $this->recentRuns(); + $commercialLifecycle = $this->workspaceCommercialLifecycleSummary(); + $commercialBadge = BadgeCatalog::spec(BadgeDomain::CommercialLifecycleState, $commercialLifecycle['state'] ?? null); + $commercialActionDecisions = is_array($commercialLifecycle['action_decisions'] ?? null) ? $commercialLifecycle['action_decisions'] : []; + $activationLifecycleDecision = $commercialActionDecisions['managed_tenant_activation'] ?? null; + $reviewPackLifecycleDecision = $commercialActionDecisions['review_pack_start'] ?? null; + $readOnlyLifecycleDecision = $commercialActionDecisions['generated_pack_read'] ?? null; $workspaceEntitlementSummary = $this->workspaceEntitlementSummary(); $planProfile = $workspaceEntitlementSummary['plan_profile'] ?? null; $entitlementDecisions = $workspaceEntitlementSummary['decisions'] ?? []; @@ -40,6 +49,63 @@ @include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision]) @endif + + + Commercial lifecycle + + +
+
+

Current state

+
+ + {{ $commercialBadge->label }} + + {{ $commercialLifecycle['source_label'] ?? 'default active paid' }} +
+

{{ $commercialLifecycle['description'] ?? 'Commercial lifecycle state controls expansion and review-pack starts.' }}

+
+ +
+

Lifecycle rationale

+

{{ $commercialLifecycle['rationale'] ?? 'No explicit rationale recorded.' }}

+

+ {{ $commercialLifecycle['last_changed_by'] ?? 'System default' }} + @if (($commercialLifecycle['last_changed_at'] ?? null) instanceof \Carbon\CarbonInterface) + · {{ $commercialLifecycle['last_changed_at']->diffForHumans() }} + @endif +

+
+
+ +
+ @foreach ([ + 'Managed tenant activation' => $activationLifecycleDecision, + 'Review-pack starts' => $reviewPackLifecycleDecision, + 'Read-only history and downloads' => $readOnlyLifecycleDecision, + ] as $label => $decision) + @if (is_array($decision)) +
+
+
+

{{ $label }}

+

{{ $decision['message'] ?? 'No lifecycle decision message available.' }}

+
+ + {{ str_replace('_', ' ', (string) ($decision['outcome'] ?? 'allow')) }} + +
+
+ @endif + @endforeach +
+
+ @if (is_array($planProfile) && is_array($managedTenantDecision) && is_array($reviewPackDecision)) diff --git a/apps/platform/resources/views/filament/widgets/tenant/tenant-review-pack-card.blade.php b/apps/platform/resources/views/filament/widgets/tenant/tenant-review-pack-card.blade.php index f7a0ffda..8aa2d392 100644 --- a/apps/platform/resources/views/filament/widgets/tenant/tenant-review-pack-card.blade.php +++ b/apps/platform/resources/views/filament/widgets/tenant/tenant-review-pack-card.blade.php @@ -11,6 +11,7 @@ /** @var bool $canManage */ /** @var bool $generationBlocked */ /** @var ?string $generationBlockReason */ + /** @var ?string $generationWarningReason */ /** @var ?string $customerWorkspaceUrl */ /** @var ?string $downloadUrl */ /** @var ?string $failedReason */ @@ -33,6 +34,12 @@ @endif + @if ($canManage && ! $generationBlocked && $generationWarningReason) +
+ {{ $generationWarningReason }} +
+ @endif + @if (! $pack) {{-- State 1: No pack --}}
diff --git a/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php b/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php index 5dcbe0da..38b69706 100644 --- a/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php +++ b/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php @@ -10,10 +10,14 @@ use App\Models\EvidenceSnapshotItem; use App\Models\Finding; use App\Models\OperationRun; +use App\Models\PlatformUser; use App\Models\ReviewPack; use App\Models\StoredReport; use App\Models\Tenant; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; +use App\Services\Settings\SettingsWriter; use App\Support\Auth\Capabilities; +use App\Support\Auth\PlatformCapabilities; use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Ui\GovernanceActions\GovernanceActionCatalog; @@ -68,6 +72,23 @@ function evidenceSnapshotHeaderActions(Testable $component): array return $instance->getCachedHeaderActions(); } +function suspendEvidenceSnapshotWorkspace(Tenant $tenant): void +{ + app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( + actor: PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]), + workspace: $tenant->workspace, + state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, + reason: 'Evidence read-only preservation test', + ); +} + it('renders the evidence list page for an authorized user', function (): void { $tenant = Tenant::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); @@ -207,6 +228,36 @@ function evidenceSnapshotHeaderActions(Testable $component): array ->toContain('operation_run', 'review_pack'); }); +it('keeps evidence snapshot detail accessible for readonly members while suspended read-only', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + + $snapshot = EvidenceSnapshot::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => EvidenceSnapshotStatus::Active->value, + 'completeness_state' => EvidenceCompletenessState::Complete->value, + 'summary' => ['finding_count' => 2], + 'generated_at' => now(), + ]); + + suspendEvidenceSnapshotWorkspace($tenant); + + $this->actingAs($user) + ->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'tenant')) + ->assertOk(); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()]) + ->assertActionVisible('refresh_evidence') + ->assertActionDisabled('refresh_evidence') + ->assertActionVisible('expire_snapshot') + ->assertActionDisabled('expire_snapshot'); +}); + it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void { $tenant = Tenant::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); diff --git a/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php b/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php index fd205d65..cfea8462 100644 --- a/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php +++ b/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php @@ -5,13 +5,16 @@ use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard; use App\Models\AuditLog; use App\Models\OperationRun; +use App\Models\PlatformUser; use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\TenantOnboardingSession; use App\Models\User; use App\Models\Workspace; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Settings\SettingsWriter; +use App\Support\Auth\PlatformCapabilities; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\Workspaces\WorkspaceContext; @@ -21,7 +24,12 @@ /** * @return array{workspace: Workspace, user: User, tenant: Tenant, draft: TenantOnboardingSession, component: \Livewire\Features\SupportTesting\Testable} */ -function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $limitOverride = null, ?string $overrideReason = null): array +function readyOnboardingEntitlementContext( + int $activeTenantCount = 0, + ?int $limitOverride = null, + ?string $overrideReason = null, + ?string $commercialState = null, +): array { Queue::fake(); @@ -110,6 +118,22 @@ function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $lim } } + if ($commercialState !== null) { + app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( + actor: PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]), + workspace: $workspace, + state: $commercialState, + reason: 'Onboarding entitlement test commercial state', + ); + } + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); $component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [ @@ -187,4 +211,66 @@ function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $lim $context['tenant']->refresh(); expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE); -}); \ No newline at end of file +}); + +it('allows onboarding activation while a workspace is in trial', function (): void { + $context = readyOnboardingEntitlementContext( + activeTenantCount: 0, + commercialState: WorkspaceCommercialLifecycleResolver::STATE_TRIAL, + ); + + $context['component'] + ->assertSee('Activation entitlement') + ->assertSee('Trial') + ->call('completeOnboarding'); + + $context['tenant']->refresh(); + + expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE) + ->and(AuditLog::query() + ->where('workspace_id', (int) $context['workspace']->getKey()) + ->where('action', 'managed_tenant_onboarding.activation') + ->exists())->toBeTrue(); +}); + +it('blocks onboarding activation with a grace commercial-state reason before tenant mutation', function (): void { + $context = readyOnboardingEntitlementContext( + activeTenantCount: 0, + commercialState: WorkspaceCommercialLifecycleResolver::STATE_GRACE, + ); + + $context['component'] + ->assertSee('Activation entitlement') + ->assertSee('Grace') + ->assertSee('New managed-tenant activation is frozen while this workspace is in grace.') + ->call('completeOnboarding'); + + $context['tenant']->refresh(); + + expect($context['tenant']->status)->toBe(Tenant::STATUS_ONBOARDING) + ->and(AuditLog::query() + ->where('workspace_id', (int) $context['workspace']->getKey()) + ->where('action', 'managed_tenant_onboarding.activation') + ->exists())->toBeFalse(); +}); + +it('blocks onboarding activation with a suspended read-only commercial-state reason before tenant mutation', function (): void { + $context = readyOnboardingEntitlementContext( + activeTenantCount: 0, + commercialState: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, + ); + + $context['component'] + ->assertSee('Activation entitlement') + ->assertSee('Suspended / read-only') + ->assertSee('This workspace is suspended / read-only. New managed-tenant activation is blocked') + ->call('completeOnboarding'); + + $context['tenant']->refresh(); + + expect($context['tenant']->status)->toBe(Tenant::STATUS_ONBOARDING) + ->and(AuditLog::query() + ->where('workspace_id', (int) $context['workspace']->getKey()) + ->where('action', 'managed_tenant_onboarding.activation') + ->exists())->toBeFalse(); +}); diff --git a/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php b/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php index 21aac9dd..1389f2f2 100644 --- a/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php +++ b/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php @@ -4,8 +4,12 @@ use App\Models\ReviewPack; use App\Models\AuditLog; +use App\Models\PlatformUser; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\ReviewPackService; +use App\Services\Settings\SettingsWriter; use App\Support\Audit\AuditActionId; +use App\Support\Auth\PlatformCapabilities; use App\Support\ReviewPackStatus; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Storage; @@ -38,6 +42,23 @@ function createReadyPackWithFile(?array $packOverrides = []): array return [$user, $tenant, $pack]; } +function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void +{ + app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( + actor: PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]), + workspace: $pack->workspace, + state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, + reason: 'Download preservation test', + ); +} + // ─── Happy Path: Signed URL → 200 ─────────────────────────── it('downloads a ready pack via signed URL with correct headers', function (): void { @@ -64,6 +85,21 @@ function createReadyPackWithFile(?array $packOverrides = []): array ->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace'); }); +it('keeps ready pack downloads available while the workspace is suspended read-only', function (): void { + [$user, $tenant, $pack] = createReadyPackWithFile(); + suspendReadyPackWorkspaceForDownloadTest($pack); + + $signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [ + 'source_surface' => 'suspended_read_only_check', + ]); + + $response = $this->actingAs($user)->get($signedUrl); + + $response->assertOk(); + $response->assertHeader('X-Review-Pack-SHA256', $pack->sha256); + $response->assertDownload(); +}); + // ─── Expired Signature → 403 ──────────────────────────────── it('rejects requests with an expired signature', function (): void { diff --git a/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php b/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php index 0e74d326..16c008a0 100644 --- a/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php +++ b/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php @@ -8,16 +8,20 @@ use App\Models\EvidenceSnapshot; use App\Models\Finding; use App\Models\OperationRun; +use App\Models\PlatformUser; use App\Models\ReviewPack; use App\Models\StoredReport; use App\Models\Tenant; use App\Models\User; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Evidence\EvidenceSnapshotService; use App\Services\ReviewPackService; use App\Services\Settings\SettingsWriter; +use App\Support\Auth\PlatformCapabilities; use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\OperationRunType; use App\Support\Workspaces\WorkspaceContext; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Storage; use Livewire\Livewire; @@ -108,6 +112,23 @@ function disableReviewPackGenerationForWorkspace(Tenant $tenant, User $user, str ); } +function setReviewPackCommercialLifecycleState(Tenant $tenant, string $state, string $reason = 'Review pack commercial lifecycle test'): void +{ + app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( + actor: PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]), + workspace: $tenant->workspace, + state: $state, + reason: $reason, + ); +} + it('blocks new review pack generation before creating a review pack or operation run when the workspace is not entitled', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); seedEntitlementReviewPackSnapshot($tenant); @@ -187,4 +208,87 @@ function disableReviewPackGenerationForWorkspace(Tenant $tenant, User $user, str ->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'tenant')) ->assertOk() ->assertSee('Download'); -}); \ No newline at end of file +}); + +it('allows review pack generation in trial and active paid states', function (string $state): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + seedEntitlementReviewPackSnapshot($tenant); + setReviewPackCommercialLifecycleState($tenant, $state); + + $pack = app(ReviewPackService::class)->generate($tenant, $user); + + expect($pack)->toBeInstanceOf(ReviewPack::class) + ->and($pack->operation_run_id)->not->toBeNull() + ->and($pack->status)->toBe(\App\Support\ReviewPackStatus::Queued->value); +})->with([ + 'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL], + 'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID], +]); + +it('warns but allows review pack generation in grace', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + seedEntitlementReviewPackSnapshot($tenant); + setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace period'); + + $decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant); + + expect($decision) + ->toMatchArray([ + 'is_blocked' => false, + 'is_warning' => true, + 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_WARN, + ]) + ->and($decision['warning_reason'])->toContain('grace'); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + setTenantPanelContext($tenant); + + Livewire::actingAs($user) + ->test(TenantReviewPackCard::class, ['record' => $tenant]) + ->assertSee('Workspace is in grace. Review-pack starts remain available'); + + $pack = app(ReviewPackService::class)->generate($tenant, $user); + + expect($pack)->toBeInstanceOf(ReviewPack::class) + ->and(OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', OperationRunType::ReviewPackGenerate->value) + ->exists())->toBeTrue(); +}); + +it('blocks suspended read-only review pack generation before creating a review pack or operation run and sends no run notifications', function (): void { + Notification::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + seedEntitlementReviewPackSnapshot($tenant); + setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspension'); + $initialRunCount = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', OperationRunType::ReviewPackGenerate->value) + ->count(); + + expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user)) + ->toThrow(WorkspaceEntitlementBlockedException::class, 'suspended / read-only'); + + expect(ReviewPack::query()->count())->toBe(0) + ->and(OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', OperationRunType::ReviewPackGenerate->value) + ->count())->toBe($initialRunCount); + + Notification::assertNothingSent(); +}); + +it('does not alter already queued review-pack work when a workspace is suspended later', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + seedEntitlementReviewPackSnapshot($tenant); + + $pack = app(ReviewPackService::class)->generate($tenant, $user); + $initialStatus = (string) $pack->fresh()?->status; + setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Later suspension'); + + expect($pack->fresh()?->status)->toBe($initialStatus) + ->and(OperationRun::query() + ->whereKey((int) $pack->operation_run_id) + ->exists())->toBeTrue(); +}); diff --git a/apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php b/apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php index 49898eda..7f8392c7 100644 --- a/apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php +++ b/apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php @@ -3,18 +3,23 @@ declare(strict_types=1); use App\Exceptions\ReviewPackEvidenceResolutionException; +use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException; use App\Filament\Widgets\Tenant\TenantReviewPackCard; use App\Jobs\GenerateReviewPackJob; use App\Models\EvidenceSnapshot; use App\Models\Finding; use App\Models\OperationRun; +use App\Models\PlatformUser; use App\Models\ReviewPack; use App\Models\StoredReport; use App\Models\Tenant; use App\Notifications\OperationRunCompleted; use App\Notifications\OperationRunQueued; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Evidence\EvidenceSnapshotService; use App\Services\ReviewPackService; +use App\Services\Settings\SettingsWriter; +use App\Support\Auth\PlatformCapabilities; use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; @@ -157,6 +162,23 @@ function createEvidenceSnapshotForReviewPack(Tenant $tenant): EvidenceSnapshot return $snapshot->load('items'); } +function suspendReviewPackGenerationWorkspaceForGenerationTest(Tenant $tenant): void +{ + app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( + actor: PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]), + workspace: $tenant->workspace, + state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, + reason: 'Generation notification boundary test', + ); +} + // ─── Happy Path ────────────────────────────────────────────── it('generates a review pack end-to-end (happy path)', function (): void { @@ -210,6 +232,22 @@ function createEvidenceSnapshotForReviewPack(Tenant $tenant): EvidenceSnapshot Notification::assertSentTo($user, OperationRunCompleted::class); }); +it('does not send queued or terminal run notifications when suspended read-only blocks generation', function (): void { + [$user, $tenant] = createUserWithTenant(); + + seedTenantWithData($tenant); + createEvidenceSnapshotForReviewPack($tenant); + suspendReviewPackGenerationWorkspaceForGenerationTest($tenant); + + Notification::fake(); + + expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user)) + ->toThrow(WorkspaceEntitlementBlockedException::class, 'suspended / read-only'); + + Notification::assertNotSentTo($user, OperationRunQueued::class); + Notification::assertNotSentTo($user, OperationRunCompleted::class); +}); + // ─── Failure Path ────────────────────────────────────────────── it('marks pack as failed when generation throws an exception', function (): void { diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php index 3fc30c6d..7cd1a4a4 100644 --- a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php @@ -3,8 +3,12 @@ declare(strict_types=1); use App\Filament\Pages\Reviews\CustomerReviewWorkspace; +use App\Models\PlatformUser; use App\Models\ReviewPack; use App\Models\Tenant; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; +use App\Services\Settings\SettingsWriter; +use App\Support\Auth\PlatformCapabilities; use App\Support\TenantReviewStatus; use App\Support\Workspaces\WorkspaceContext; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -12,6 +16,23 @@ uses(RefreshDatabase::class); +function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void +{ + app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( + actor: PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]), + workspace: $tenant->workspace, + state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, + reason: 'Customer review workspace suspended read-only test', + ); +} + it('shows the ready review-pack action for the latest published review', function (): void { $tenant = Tenant::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); @@ -48,6 +69,44 @@ ->assertSee('Available'); }); +it('keeps customer review workspace and pack actions visible while suspended read-only', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + $snapshot = seedTenantReviewEvidence($tenant); + + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $review->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $pack = ReviewPack::factory()->ready()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'expires_at' => now()->addDay(), + ]); + + $review->forceFill([ + 'current_export_review_pack_id' => (int) $pack->getKey(), + ])->save(); + + suspendCustomerReviewWorkspacePackAccessWorkspace($tenant); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + Livewire::actingAs($user) + ->test(CustomerReviewWorkspace::class) + ->assertTableActionVisible('open_latest_review', $tenant) + ->assertTableActionVisible('download_review_pack', $tenant) + ->assertSee('Available'); +}); + it('shows an unavailable pack state and hides the download action when no current review pack exists', function (): void { $tenant = Tenant::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); @@ -94,4 +153,4 @@ ->assertTableActionHidden('open_latest_review', $tenant) ->assertTableActionHidden('download_review_pack', $tenant) ->assertSee('No published review available yet'); -}); \ No newline at end of file +}); diff --git a/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php b/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php index 232fc0fc..55a1d08c 100644 --- a/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php +++ b/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php @@ -5,7 +5,9 @@ use App\Models\OperationRun; use App\Models\PlatformUser; use App\Models\User; +use App\Models\Workspace; use App\Support\Auth\PlatformCapabilities; +use App\Support\System\SystemDirectoryLinks; use App\Support\System\SystemOperationRunLinks; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -119,3 +121,38 @@ ->get('/system/ops/runbooks') ->assertSuccessful(); }); + +it('keeps system workspace detail route semantics separate from commercial business-state blocks', function (): void { + $workspace = Workspace::factory()->create(); + + $this->actingAs(User::factory()->create()) + ->get(SystemDirectoryLinks::workspaceDetail($workspace)) + ->assertNotFound(); + + auth()->guard('web')->logout(); + + $platformWithoutDirectoryView = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + ], + 'is_active' => true, + ]); + + $this->actingAs($platformWithoutDirectoryView, 'platform') + ->get(SystemDirectoryLinks::workspaceDetail($workspace)) + ->assertForbidden(); + + $directoryViewer = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + ], + 'is_active' => true, + ]); + + $this->actingAs($directoryViewer, 'platform') + ->get(SystemDirectoryLinks::workspaceDetail($workspace)) + ->assertSuccessful() + ->assertSee('Commercial lifecycle') + ->assertDontSee('Change commercial state'); +}); diff --git a/apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php b/apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php index e0d8fad1..88514d6d 100644 --- a/apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php +++ b/apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php @@ -3,13 +3,25 @@ declare(strict_types=1); use App\Filament\System\Pages\Directory\ViewWorkspace; +use App\Models\AuditLog; use App\Models\PlatformUser; use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Models\WorkspaceSetting; +use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Settings\SettingsWriter; +use App\Support\Audit\AuditActionId; use App\Support\Auth\PlatformCapabilities; +use Filament\Actions\Action; +use Filament\Facades\Filament; +use Livewire\Livewire; + +beforeEach(function (): void { + Filament::setCurrentPanel('system'); + Filament::bootCurrentPanel(); +}); it('renders the read-only workspace entitlement summary on the system workspace detail page', function (): void { $workspace = Workspace::factory()->create(['name' => 'Acme Workspace']); @@ -79,5 +91,102 @@ ->assertSee('Pilot workspace') ->assertSee('Escalation only') ->assertSee('workspace override') + ->assertSee('Commercial lifecycle') + ->assertSee('Active paid') + ->assertSee('default active paid') ->assertDontSee('Save'); -}); \ No newline at end of file +}); + +it('gates the commercial lifecycle mutation action behind a dedicated platform capability', function (): void { + $workspace = Workspace::factory()->create(); + + $viewer = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + ], + 'is_active' => true, + ]); + + Livewire::actingAs($viewer, 'platform') + ->test(ViewWorkspace::class, ['workspace' => $workspace]) + ->assertActionHidden('change_commercial_state'); +}); + +it('changes commercial lifecycle state through the confirmed system action and records audit truth', function (): void { + $workspace = Workspace::factory()->create(['name' => 'Lifecycle Workspace']); + $operator = PlatformUser::factory()->create([ + 'name' => 'Platform Operator', + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]); + + Livewire::actingAs($operator, 'platform') + ->test(ViewWorkspace::class, ['workspace' => $workspace]) + ->assertActionVisible('change_commercial_state') + ->assertActionExists('change_commercial_state', fn (Action $action): bool => $action->getLabel() === 'Change commercial state' + && $action->isConfirmationRequired()) + ->callAction('change_commercial_state', data: [ + 'state' => WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, + 'reason' => 'Commercial suspension approved by support', + ]) + ->assertNotified('Commercial state updated'); + + expect(WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN) + ->where('key', WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE) + ->value('value'))->toBe(WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY) + ->and(WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN) + ->where('key', WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON) + ->value('value'))->toBe('Commercial suspension approved by support'); + + $audit = AuditLog::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('action', AuditActionId::WorkspaceSettingUpdated->value) + ->where('resource_id', WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN.'.'.WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE) + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull() + ->and($audit?->actor_name)->toBe('Platform Operator') + ->and($audit?->metadata['before_state'] ?? null)->toBeNull() + ->and($audit?->metadata['after_state'] ?? null)->toBe(WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY) + ->and($audit?->metadata['after_reason'] ?? null)->toBe('Commercial suspension approved by support'); + + $summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace); + + expect($summary) + ->toMatchArray([ + 'state' => WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, + 'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING, + 'rationale' => 'Commercial suspension approved by support', + 'last_changed_by' => 'Platform Operator', + ]); +}); + +it('requires a rationale before changing commercial lifecycle state', function (): void { + $workspace = Workspace::factory()->create(); + $operator = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]); + + Livewire::actingAs($operator, 'platform') + ->test(ViewWorkspace::class, ['workspace' => $workspace]) + ->callAction('change_commercial_state', data: [ + 'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE, + 'reason' => '', + ]) + ->assertHasActionErrors(['reason']); +}); diff --git a/apps/platform/tests/Unit/Badges/CommercialLifecycleStateBadgeTest.php b/apps/platform/tests/Unit/Badges/CommercialLifecycleStateBadgeTest.php new file mode 100644 index 00000000..e5279451 --- /dev/null +++ b/apps/platform/tests/Unit/Badges/CommercialLifecycleStateBadgeTest.php @@ -0,0 +1,20 @@ +label)->toBe($label) + ->and($spec->color)->toBe($color) + ->and($spec->icon)->not->toBeNull(); +})->with([ + 'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL, 'Trial', 'info'], + 'grace' => [WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace', 'warning'], + 'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, 'Active paid', 'success'], + 'suspended read-only' => [WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspended / read-only', 'danger'], +]); diff --git a/apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php b/apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php new file mode 100644 index 00000000..083a5326 --- /dev/null +++ b/apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php @@ -0,0 +1,199 @@ +create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'manager', + ]); + + return [$workspace, $user]; +} + +function commercialLifecyclePlatformOperator(): PlatformUser +{ + return PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, + ], + 'is_active' => true, + ]); +} + +function setCommercialLifecycleState(Workspace $workspace, string $state, string $reason = 'Unit test commercial state change'): void +{ + app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( + actor: commercialLifecyclePlatformOperator(), + workspace: $workspace, + state: $state, + reason: $reason, + ); +} + +it('falls back to active paid when no explicit commercial lifecycle setting exists', function (): void { + [$workspace] = commercialLifecycleWorkspaceManager(); + + $summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace); + + expect($summary) + ->toMatchArray([ + 'state' => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, + 'state_label' => 'Active paid', + 'source' => WorkspaceCommercialLifecycleResolver::SOURCE_DEFAULT_ACTIVE_PAID, + 'source_label' => 'default active paid', + 'rationale' => null, + ]) + ->and($summary['last_changed_at'])->toBeNull() + ->and($summary['last_changed_by'])->toBeNull() + ->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION]['outcome']) + ->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW) + ->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START]['outcome']) + ->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW); +}); + +it('resolves explicit stored commercial lifecycle states with source rationale and platform attribution', function (string $state, string $expectedLabel): void { + [$workspace] = commercialLifecycleWorkspaceManager(); + $operator = commercialLifecyclePlatformOperator(); + + app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( + actor: $operator, + workspace: $workspace, + state: $state, + reason: 'Support approved commercial lifecycle transition', + ); + + $summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace); + + expect($summary) + ->toMatchArray([ + 'state' => $state, + 'state_label' => $expectedLabel, + 'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING, + 'source_label' => 'workspace setting', + 'rationale' => 'Support approved commercial lifecycle transition', + 'last_changed_by' => $operator->name, + ]) + ->and($summary['last_changed_at'])->not->toBeNull(); +})->with([ + 'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL, 'Trial'], + 'grace' => [WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace'], + 'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, 'Active paid'], + 'suspended read-only' => [WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspended / read-only'], +]); + +it('blocks activation but warns review pack starts during grace', function (): void { + [$workspace] = commercialLifecycleWorkspaceManager(); + setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Payment collection pending'); + + $resolver = app(WorkspaceCommercialLifecycleResolver::class); + $activation = $resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION); + $reviewPackStart = $resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START); + + expect($activation) + ->toMatchArray([ + 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK, + 'is_blocked' => true, + 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + 'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE, + ]) + ->and($activation['block_reason'])->toContain('grace') + ->and($reviewPackStart) + ->toMatchArray([ + 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_WARN, + 'is_blocked' => false, + 'is_warning' => true, + 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + 'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE, + ]) + ->and($reviewPackStart['warning_reason'])->toContain('grace'); +}); + +it('blocks new starts but allows read-only history during suspended read-only', function (): void { + [$workspace] = commercialLifecycleWorkspaceManager(); + setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Commercial suspension'); + + $resolver = app(WorkspaceCommercialLifecycleResolver::class); + + expect($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION)) + ->toMatchArray([ + 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK, + 'is_blocked' => true, + 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + ]) + ->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START)) + ->toMatchArray([ + 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK, + 'is_blocked' => true, + 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + ]) + ->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_EVIDENCE_READ)) + ->toMatchArray([ + 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW_READ_ONLY, + 'is_blocked' => false, + 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE, + ]); +}); + +it('preserves entitlement substrate blocks ahead of lifecycle outcomes', function (): void { + [$workspace, $manager] = commercialLifecycleWorkspaceManager(); + setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace should not bypass substrate'); + + Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'status' => Tenant::STATUS_ACTIVE, + ]); + + app(SettingsWriter::class)->updateWorkspaceSetting( + actor: $manager, + workspace: $workspace, + domain: WorkspaceEntitlementResolver::SETTING_DOMAIN, + key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE, + value: 1, + ); + + app(SettingsWriter::class)->updateWorkspaceSetting( + actor: $manager, + workspace: $workspace, + domain: WorkspaceEntitlementResolver::SETTING_DOMAIN, + key: WorkspaceEntitlementResolver::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE, + value: false, + ); + + $resolver = app(WorkspaceCommercialLifecycleResolver::class); + + expect($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION)) + ->toMatchArray([ + 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK, + 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_ENTITLEMENT_SUBSTRATE, + ]) + ->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START)) + ->toMatchArray([ + 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK, + 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_ENTITLEMENT_SUBSTRATE, + 'is_warning' => false, + ]); +}); diff --git a/apps/platform/tests/Unit/Support/Workspaces/WorkspaceResolverTest.php b/apps/platform/tests/Unit/Support/Workspaces/WorkspaceResolverTest.php new file mode 100644 index 00000000..a090a43f --- /dev/null +++ b/apps/platform/tests/Unit/Support/Workspaces/WorkspaceResolverTest.php @@ -0,0 +1,52 @@ +create([ + 'slug' => 'resolver-smoke-workspace', + ]); + + $resolver = app(WorkspaceResolver::class); + + expect($resolver->resolve('resolver-smoke-workspace')?->is($workspace))->toBeTrue() + ->and($resolver->resolve((string) $workspace->getKey())?->is($workspace))->toBeTrue(); +}); + +it('resolves a Livewire serialized workspace route parameter', function (): void { + $workspace = Workspace::factory()->create([ + 'slug' => 'serialized-route-workspace', + ]); + + $payload = json_encode([ + 'id' => $workspace->getKey(), + 'name' => $workspace->name, + 'slug' => $workspace->slug, + ], JSON_THROW_ON_ERROR); + + expect(app(WorkspaceResolver::class)->resolve($payload)?->is($workspace))->toBeTrue(); +}); + +it('falls back to serialized id when a Livewire route payload has no slug', function (): void { + $workspace = Workspace::factory()->create(); + + $payload = json_encode([ + 'id' => (string) $workspace->getKey(), + ], JSON_THROW_ON_ERROR); + + expect(app(WorkspaceResolver::class)->resolve($payload)?->is($workspace))->toBeTrue(); +}); + +it('returns null for an unsupported serialized route payload', function (): void { + $payload = json_encode([ + 'name' => 'Missing key', + ], JSON_THROW_ON_ERROR); + + expect(app(WorkspaceResolver::class)->resolve($payload))->toBeNull(); +}); diff --git a/get_gitea_tools.py b/get_gitea_tools.py new file mode 100644 index 00000000..e0a9f121 --- /dev/null +++ b/get_gitea_tools.py @@ -0,0 +1,50 @@ +import json +import subprocess +import sys +import time + +def send(proc, payload): + proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8")) + proc.stdin.flush() + +def read_line(proc, timeout=10.0): + start = time.time() + while time.time() - start < timeout: + line = proc.stdout.readline() + if line: + return line.decode("utf-8", errors="replace").strip() + time.sleep(0.05) + return "" + +def main(): + proc = subprocess.Popen( + ["python3", "scripts/run-gitea-mcp.py"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + send(proc, { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1.0.0"}, + }, + }) + init_resp = read_line(proc) + send(proc, { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + }) + tools_resp = read_line(proc) + print(tools_resp) + finally: + proc.terminate() + +if __name__ == "__main__": + main() diff --git a/specs/251-commercial-entitlements-billing-state/checklists/requirements.md b/specs/251-commercial-entitlements-billing-state/checklists/requirements.md new file mode 100644 index 00000000..c2fdb0ee --- /dev/null +++ b/specs/251-commercial-entitlements-billing-state/checklists/requirements.md @@ -0,0 +1,42 @@ +# Specification Quality Checklist: Commercial Entitlements and Billing-State Maturity + +**Purpose**: Validate specification completeness and readiness before planning or implementation. +**Created**: 2026-04-28 +**Feature**: [../spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Review Outcome + +- [x] Review outcome class: acceptable-special-case +- [x] Workflow outcome: keep +- [x] Test-governance impact is explicitly recorded in the spec + +## Notes + +- Repo-specific surface names and existing product terms are used to anchor the spec to current truth, but the spec does not prescribe languages, frameworks, APIs, or low-level implementation design. +- No open clarification markers remain. The bounded assumptions are the default `active_paid` resolution for unset workspaces and the distinct `grace` behavior that freezes onboarding expansion without blocking in-scope review-pack starts. +- Implementation close-out keeps the workflow outcome as `keep`. The Livewire browser-smoke finding was fixed inside scope by making workspace route resolution accept Livewire serialized workspace parameters; no follow-up spec is required. diff --git a/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml b/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml new file mode 100644 index 00000000..b1968aaf --- /dev/null +++ b/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml @@ -0,0 +1,465 @@ +openapi: 3.0.3 +info: + title: TenantPilot Admin/System - Workspace Commercial Lifecycle Overlay (Conceptual) + version: 0.1.0 + description: | + Conceptual contract for the commercial lifecycle overlay that follows the + existing workspace entitlement substrate from Spec 247. + + NOTE: These routes are implemented as existing Filament pages, resources, + widgets, and Livewire-backed actions. Exact Livewire payload shapes are not + part of this contract. This file captures logical route boundaries, the + system/admin split, and the required 404 / 403 / business-state semantics. +servers: + - url: /admin + - url: /system +paths: + /directory/workspaces/{workspace}: + get: + summary: View read-only workspace commercial lifecycle summary in the system plane + description: | + Renders the existing system directory workspace detail page with the + effective lifecycle state, rationale, affected behavior summary, and the + reused entitlement substrate summary. + parameters: + - $ref: '#/components/parameters/WorkspaceId' + responses: + '200': + description: System workspace detail rendered + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/SystemWorkspaceCommercialLifecycleView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /directory/workspaces/{workspace}/actions/change-commercial-state: + post: + summary: Change the workspace commercial lifecycle state from the system plane + description: | + Conceptual contract for the confirmation-protected state-change action + on the existing system workspace detail page. + + Behavior: + - Platform user with directory visibility but without the dedicated + lifecycle-manage capability: 403 + - Wrong plane or non-platform actor: 404 semantics at the panel boundary + - Authorized platform user: state and rationale are written through the + existing workspace settings audit path + parameters: + - $ref: '#/components/parameters/WorkspaceId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChangeCommercialLifecycleCommand' + responses: + '204': + description: Commercial lifecycle state changed successfully + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + /onboarding/{onboardingDraft}: + get: + summary: View onboarding workflow with lifecycle-aware completion state + description: | + Renders the existing managed-tenant onboarding wizard. The completion + step must include the commercial lifecycle outcome after the underlying + entitlement substrate has been evaluated. + parameters: + - $ref: '#/components/parameters/OnboardingDraftId' + responses: + '200': + description: Onboarding wizard rendered + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/OnboardingCommercialLifecycleView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /onboarding/{onboardingDraft}/actions/complete: + post: + summary: Complete onboarding when entitlement, lifecycle state, and existing readiness all allow + parameters: + - $ref: '#/components/parameters/OnboardingDraftId' + responses: + '204': + description: Onboarding completed + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/BusinessStateBlocked' + /review-packs/actions/generate: + post: + summary: Generate a review pack from the current tenant context + description: | + Conceptual contract for the tenant dashboard and review-pack list start + action family. + + Behavior ordering: + 1. authorization + 2. underlying entitlement substrate decision + 3. lifecycle overlay decision + 4. existing dedupe / queued-start flow when allowed + + A lifecycle-blocked attempt is future-start-only in this slice: it + creates no new `ReviewPack`, creates no new `OperationRun`, emits no + queued or terminal review-pack notification, and does not affect any + review-pack work that was already queued or running. + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/ReviewPackGenerationCommand' + responses: + '202': + description: Generation accepted or deduped through the existing flow + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/BusinessStateBlocked' + /tenant-reviews/{tenantReview}/actions/export-executive-pack: + post: + summary: Export an executive pack from an existing tenant review + description: | + Conceptual contract for the review register and tenant review detail + export action family. The lifecycle overlay must block before any new + `ReviewPack` or `OperationRun` is created, emit no queued or terminal + review-pack notification for the blocked attempt, and leave any + already-created queued or running review-pack work unchanged. + parameters: + - $ref: '#/components/parameters/TenantReviewId' + responses: + '202': + description: Export accepted or deduped through the existing flow + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/BusinessStateBlocked' + /review-packs/{reviewPack}/actions/regenerate: + post: + summary: Regenerate an existing review pack + description: | + Conceptual contract for the existing review-pack detail regenerate + action. Existing confirmation and dedupe behavior remain in place when + the lifecycle overlay allows the start. A lifecycle-blocked attempt is + future-start-only: it creates no new `ReviewPack`, creates no new + `OperationRun`, emits no queued or terminal review-pack notification, + and leaves any already-created queued or running review-pack work + unchanged. + parameters: + - $ref: '#/components/parameters/ReviewPackId' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/ReviewPackGenerationCommand' + responses: + '202': + description: Regeneration accepted or deduped through the existing flow + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/BusinessStateBlocked' + /tenant-reviews/{tenantReview}: + get: + summary: View existing tenant review while the workspace may be suspended read-only + parameters: + - $ref: '#/components/parameters/TenantReviewId' + responses: + '200': + description: Existing tenant review rendered when current RBAC allows it + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/PreservedReadOnlyView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /review-packs/{reviewPack}: + get: + summary: View existing review pack while the workspace may be suspended read-only + parameters: + - $ref: '#/components/parameters/ReviewPackId' + responses: + '200': + description: Existing review pack rendered when current RBAC allows it + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/PreservedReadOnlyView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /review-packs/{reviewPack}/download: + get: + summary: Download an already-generated review pack while the workspace may be suspended read-only + parameters: + - $ref: '#/components/parameters/ReviewPackId' + responses: + '200': + description: Existing generated pack download is still available when current RBAC allows it + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /evidence-snapshots/{evidenceSnapshot}: + get: + summary: View existing evidence snapshot while the workspace may be suspended read-only + parameters: + - $ref: '#/components/parameters/EvidenceSnapshotId' + responses: + '200': + description: Existing evidence snapshot rendered when current RBAC allows it + content: + text/html: + schema: + type: string + x-logical-view-model: + $ref: '#/components/schemas/PreservedReadOnlyView' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' +components: + parameters: + WorkspaceId: + name: workspace + in: path + required: true + schema: + type: integer + OnboardingDraftId: + name: onboardingDraft + in: path + required: true + schema: + type: integer + TenantReviewId: + name: tenantReview + in: path + required: true + schema: + type: integer + ReviewPackId: + name: reviewPack + in: path + required: true + schema: + type: integer + EvidenceSnapshotId: + name: evidenceSnapshot + in: path + required: true + schema: + type: integer + responses: + Forbidden: + description: Established-scope actor lacks the required capability + NotFound: + description: Wrong plane, non-member scope, or inaccessible record + BusinessStateBlocked: + description: Actor is otherwise authorized, but the workspace commercial state or underlying entitlement substrate blocks the requested action + content: + application/json: + schema: + $ref: '#/components/schemas/CommercialLifecycleBlockResponse' + ValidationError: + description: Submitted commercial lifecycle state change failed validation + schemas: + ChangeCommercialLifecycleCommand: + type: object + required: + - state + - reason + properties: + state: + $ref: '#/components/schemas/CommercialLifecycleState' + reason: + type: string + description: Required for every explicit lifecycle state change, including an explicit return to active_paid. + minLength: 1 + maxLength: 500 + CommercialLifecycleState: + type: string + enum: + - trial + - grace + - active_paid + - suspended_read_only + ReviewPackGenerationCommand: + type: object + properties: + include_pii: + type: boolean + include_operations: + type: boolean + SystemWorkspaceCommercialLifecycleView: + type: object + required: + - workspace_id + - lifecycle + - affected_behaviors + properties: + workspace_id: + type: integer + lifecycle: + $ref: '#/components/schemas/CommercialLifecycleDecision' + affected_behaviors: + type: array + items: + $ref: '#/components/schemas/CommercialLifecycleActionDecision' + entitlement_substrate: + type: object + description: Existing Spec 247 workspace entitlement summary reused for context + primary_action: + $ref: '#/components/schemas/NextAction' + nullable: true + OnboardingCommercialLifecycleView: + type: object + required: + - onboarding_draft_id + - action_decision + properties: + onboarding_draft_id: + type: integer + action_decision: + $ref: '#/components/schemas/CommercialLifecycleActionDecision' + entitlement_substrate: + type: object + nullable: true + CommercialLifecycleDecision: + type: object + required: + - state + - label + - source + - source_label + properties: + state: + $ref: '#/components/schemas/CommercialLifecycleState' + label: + type: string + source: + type: string + enum: + - default_active_paid + - workspace_setting + source_label: + type: string + description: Rendered source label from the shared lifecycle source mapping used by system detail surfaces. + rationale: + type: string + nullable: true + last_changed_at: + type: string + format: date-time + nullable: true + last_changed_by: + type: string + nullable: true + CommercialLifecycleActionDecision: + type: object + required: + - action_key + - outcome + - lifecycle_state + properties: + action_key: + type: string + enum: + - managed_tenant_activation + - review_pack_start + - review_history_read + - evidence_read + - generated_pack_read + outcome: + type: string + enum: + - allow + - warn + - block + - allow_read_only + reason_family: + type: string + nullable: true + enum: + - commercial_lifecycle + - entitlement_substrate + message: + type: string + nullable: true + lifecycle_state: + $ref: '#/components/schemas/CommercialLifecycleState' + underlying_entitlement_key: + type: string + nullable: true + CommercialLifecycleBlockResponse: + type: object + required: + - reason_family + - message + properties: + reason_family: + type: string + enum: + - commercial_lifecycle + - entitlement_substrate + lifecycle_state: + $ref: '#/components/schemas/CommercialLifecycleState' + nullable: true + message: + type: string + PreservedReadOnlyView: + type: object + required: + - read_only_access_preserved + properties: + read_only_access_preserved: + type: boolean + enum: [true] + lifecycle_state: + $ref: '#/components/schemas/CommercialLifecycleState' + message: + type: string + nullable: true + description: Optional calm explanation that the workspace is suspended read-only while current history access remains available + NextAction: + type: object + required: + - label + properties: + label: + type: string + enabled: + type: boolean + reason: + type: string + nullable: true \ No newline at end of file diff --git a/specs/251-commercial-entitlements-billing-state/data-model.md b/specs/251-commercial-entitlements-billing-state/data-model.md new file mode 100644 index 00000000..1629578d --- /dev/null +++ b/specs/251-commercial-entitlements-billing-state/data-model.md @@ -0,0 +1,170 @@ +# Data Model: Commercial Entitlements and Billing-State Maturity + +**Date**: 2026-04-28 +**Branch**: `251-commercial-entitlements-billing-state` + +## Overview + +This slice adds no new table. Persisted truth stays in existing `workspace_settings` rows, while the commercial lifecycle overlay and action-family outcomes remain derived. + +## Persisted Truth + +### 1. Workspace Commercial Lifecycle Setting Aggregate + +**Persistence**: Existing `App\Models\WorkspaceSetting` rows +**Ownership**: Workspace-owned +**Scope**: One workspace, no new tenant-owned or platform-owned persistence + +The slice reuses explicit settings keys under the existing `entitlements` domain. + +| Setting key | Type | Nullable | Validation | Notes | +|-------------|------|----------|------------|-------| +| `entitlements.commercial_lifecycle_state` | string | yes | when present, must be one of `trial`, `grace`, `active_paid`, `suspended_read_only` | `null` means the workspace has never been explicitly set and resolves to the implicit default `active_paid` | +| `entitlements.commercial_lifecycle_reason` | string | yes | required on every explicit lifecycle state change; trimmed; max 500 chars | Operator-entered rationale shown on system and contextual admin surfaces | + +**Write rules**: + +- Lifecycle mutation happens from the system plane only and updates state plus rationale together through the existing workspace settings write/audit path. +- The future `Change commercial state` action is confirmation-protected and requires explicit rationale for every explicit lifecycle transition, including an explicit return to `active_paid`. +- Once a platform operator explicitly sets `active_paid`, that remains a stored state like the other three values. `null` is reserved for untouched workspaces only. + +**Relationships**: + +- `workspace_settings.workspace_id` anchors lifecycle truth to the workspace. +- `workspace_settings.updated_by_user_id` remains the attribution source for state change metadata. + +## Existing Substrate Truth Reused + +### 2. Workspace Entitlement Substrate Summary + +**Persistence**: Existing Spec 247 workspace entitlement settings + code-owned plan-profile catalog +**Owner**: `WorkspaceEntitlementResolver` + +This slice does not remodel the substrate. It reuses: + +- `plan_profile` +- `managed_tenant_activation_limit` +- `review_pack_generation_enabled` +- substrate rationale/source/current-usage metadata + +The lifecycle overlay may warn or restrict after substrate resolution, but it must never expand access beyond what the substrate already allows. + +## Code-Owned Truth + +### 3. Commercial Lifecycle State Catalog Entry + +**Persistence**: none, code-owned +**Ownership**: Product/runtime configuration +**Scope**: current release only + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `id` | string | yes | Stable internal identifier stored in `entitlements.commercial_lifecycle_state` | +| `label` | string | yes | Operator-facing state label | +| `description` | string | yes | Short explanation for system detail and contextual messaging | +| `onboarding_outcome` | string | yes | `allow` or `block` | +| `review_pack_start_outcome` | string | yes | `allow`, `warn`, or `block` | +| `preserves_read_only_history` | bool | yes | Whether existing review/evidence/generated-pack consumption remains explicitly preserved | +| `is_default` | bool | yes | Exactly one default entry: `active_paid` | + +**Behavior matrix**: + +| State | Onboarding activation | Review-pack starts | Existing review/evidence/download access | +|-------|-----------------------|--------------------|------------------------------------------| +| `trial` | allow | allow | allow | +| `active_paid` | allow | allow | allow | +| `grace` | block | warn (start still allowed) | allow | +| `suspended_read_only` | block | block | allow | + +## Derived Truth + +### 4. Effective Commercial Lifecycle Decision + +**Persistence**: none, derived at runtime +**Owner**: bounded `WorkspaceCommercialLifecycleResolver` + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `workspace_id` | int | yes | Workspace being evaluated | +| `state` | string | yes | Effective lifecycle state | +| `label` | string | yes | Operator-facing label | +| `source` | string | yes | `default_active_paid` or `workspace_setting`; any rendered source label must come from one shared mapping | +| `rationale` | string | no | Explicit operator rationale when source is `workspace_setting` | +| `last_changed_at` | datetime | no | Derived from the most recent lifecycle-related `WorkspaceSetting` row | +| `last_changed_by` | string | no | Derived actor attribution | +| `entitlement_summary` | object | yes | Existing Spec 247 substrate summary reused for support/context | +| `action_decisions` | object | yes | Per-action-family outcomes described below | + +### 5. Commercial Lifecycle Action Decision + +**Persistence**: none, derived at runtime + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `action_key` | string | yes | One of `managed_tenant_activation`, `review_pack_start`, `review_history_read`, `evidence_read`, `generated_pack_read` | +| `outcome` | string | yes | `allow`, `warn`, `block`, or `allow_read_only` | +| `reason_family` | string | no | `commercial_lifecycle`, `entitlement_substrate`, or `null` when fully allowed | +| `message` | string | no | Operator-safe explanation or warning | +| `lifecycle_state` | string | yes | Effective state that produced the action decision | +| `underlying_entitlement_key` | string | no | Present for onboarding/review-pack start decisions to preserve substrate traceability | + +**Decision ordering rules**: + +- The substrate entitlement decision runs first. +- If the substrate already blocks the action, the lifecycle overlay must not replace that reason. +- If the substrate allows the action, the lifecycle overlay may warn or block according to the state matrix. +- Authorization is not part of this derived decision; 404 and 403 semantics remain outside and happen earlier. + +## Supporting Derived View Models + +### 6. System Workspace Commercial Lifecycle View Model + +**Persistence**: none +**Consumer**: `App\Filament\System\Pages\Directory\ViewWorkspace` + +Contains: + +- effective lifecycle state, label, rationale, and last-change attribution +- the two in-scope action-family outcomes +- the reused entitlement substrate summary for support context +- the one dominant mutation affordance metadata for `Change commercial state` + +### 7. Contextual Admin Lifecycle Gate View Models + +**Persistence**: none +**Consumers**: `ManagedTenantOnboardingWizard`, review-pack entry surfaces, and suspended read-only history surfaces + +Contains: + +- the immediate action-family outcome (`allow`, `warn`, `block`, or `allow_read_only`) +- one operator-safe explanation +- enough substrate context to keep lifecycle blocks distinct from underlying entitlement blocks + +## Derived Query Dependencies + +| Need | Source | Notes | +|------|--------|-------| +| Underlying plan-profile and entitlement truth | `WorkspaceEntitlementResolver` | Remains the canonical substrate | +| Lifecycle last-change attribution | existing `workspace_settings.updated_by_user_id` and timestamps | Derived from lifecycle-related rows only | +| Active managed-tenant usage | existing tenant/workspace runtime truth | Reused from the substrate summary | +| Existing review/history/evidence/download availability | existing review pack, review, evidence snapshot, and RBAC truth | No new persistence needed | +| Review-pack no-run proof | existing `review_packs` and `operation_runs` tables | Used only in tests to prove blocked starts do not write new run state | + +## State Transitions + +There is no new table-backed lifecycle entity. State changes are explicit workspace-setting transitions plus audit entries. + +| From | To | Trigger | Consequence | +|------|----|---------|-------------| +| `null` (implicit default) | any explicit state | platform operator saves lifecycle state on the system detail page | workspace now has explicit commercial posture, rationale, and attribution | +| `trial` | `grace` | platform operator state change | new managed-tenant activation blocks; review-pack starts remain allowed with warning | +| `grace` | `suspended_read_only` | platform operator state change | onboarding and new review-pack starts block; history/evidence/download remain available | +| `suspended_read_only` | `active_paid` | platform operator state change | future starts again defer to underlying entitlement truth | +| any explicit state | another explicit state | platform operator state change | previous state is replaced; audit history preserves the transition trail | + +## Boundaries Explicitly Preserved + +- No new billing/customer/subscription entity exists. +- No new automated timers, expiry jobs, renewal reminders, or scheduled transitions are introduced. +- No new broad suspension contract is added for unrelated mutable surfaces. +- Existing read-only review/evidence/generated-pack access remains governed by current RBAC and redaction rules. \ No newline at end of file diff --git a/specs/251-commercial-entitlements-billing-state/plan.md b/specs/251-commercial-entitlements-billing-state/plan.md new file mode 100644 index 00000000..2517b115 --- /dev/null +++ b/specs/251-commercial-entitlements-billing-state/plan.md @@ -0,0 +1,297 @@ +# Implementation Plan: Commercial Entitlements and Billing-State Maturity + +**Branch**: `251-commercial-entitlements-billing-state` | **Date**: 2026-04-28 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +- Layer one bounded workspace commercial lifecycle overlay on top of the already-real Spec 247 entitlement substrate, not beside it. The existing `WorkspaceEntitlementResolver` remains canonical for plan/default/override truth, and the new slice adds one explicit lifecycle state plus action-family outcomes for onboarding activation, review-pack start, and preserved read-only history access. +- Keep mutation narrow and platform-owned: persist lifecycle state through the existing workspace settings infrastructure, expose inspection plus state change from the existing system workspace detail surface, and keep `/admin` limited to contextual allow, warn, or block messaging on onboarding and review-pack surfaces. +- Preserve current review/evidence/download truth while suspended. New lifecycle blocking must stop future onboarding activation and future review-pack starts before any tenant mutation, `ReviewPack`, or `OperationRun` creation, while leaving already-generated history and evidence consumption under current RBAC intact. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page +**Storage**: PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model +**Testing**: Pest unit and feature tests via Laravel Sail +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Monorepo Laravel web application in `apps/platform`, using existing Filament admin and system panels +**Project Type**: web +**Performance Goals**: Reuse existing settings reads and current workspace aggregates only, add no new external calls during render, keep review-pack dedupe and shared run UX unchanged when allowed, and short-circuit blocked review-pack starts before any `ReviewPack` or `OperationRun` write +**Constraints**: One commercial lifecycle overlay only, four bounded states, two real gated behavior families, preserved authorized read-only history/evidence/download access while suspended, explicit `/admin` vs `/system` separation, no payment provider/invoice/checkout/website/broad billing-engine scope +**Scale/Scope**: One bounded lifecycle resolver, one system-plane mutation surface, one platform capability addition, one onboarding gate, one review-pack action-family gate, and focused lifecycle/read-only test coverage + +## Filament v5 / Panel Notes + +- **Livewire v4.0+ compliance**: The slice stays inside existing Filament v5 pages, widgets, resources, and Livewire-backed actions. No Livewire v3 assumptions or compatibility work are introduced. +- **Provider registration location**: No panel/provider registration changes are planned. Existing Laravel 12 + Filament provider registration remains in `bootstrap/providers.php`. +- **Global search**: No new globally searchable resource is introduced. Current global-search behavior remains unchanged. +- **Destructive and high-impact actions**: The future `Change commercial state` action on the system workspace detail page must use `->requiresConfirmation()`, require platform authorization, and write audit history. The `Suspended / read-only` transition is the only high-risk path in scope. Review-pack and onboarding blocks remain non-destructive business-state responses, not hidden authorization failures. +- **Asset strategy**: No new panel or shared assets are planned. Deployment remains unchanged, including `cd apps/platform && php artisan filament:assets` when registered Filament assets are deployed elsewhere in the product. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: mixed +- **Shared-family relevance**: system detail controls, status messaging, onboarding helper text, review-pack action gating, review/evidence viewer messaging +- **State layers in scope**: page, detail +- **Audience modes in scope**: operator-MSP, support-platform, customer/read-only +- **Decision/diagnostic/raw hierarchy plan**: decision-first on the system workspace detail surface and on the immediate onboarding/review-pack action context; diagnostics-second via the existing entitlement substrate and review/run/history context; no new raw/support payload surface is planned +- **Raw/support gating plan**: capability-gated system-plane inspection only; customer/read-only surfaces remain calm and evidence-first +- **One-primary-action / duplicate-truth control**: `/system/directory/workspaces/{workspace}` remains the only mutation surface; onboarding and review-pack surfaces show only the local lifecycle consequence required for the immediate action; suspended read-only history pages do not restate the whole commercial profile +- **Handling modes by drift class or surface**: review-mandatory because one lifecycle vocabulary must stay consistent across system, onboarding, review-pack, and read-only history surfaces +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: standard-native-filament, shared-detail-family, monitoring-state-page +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: no second admin-plane commercial mutation surface, no page-local lifecycle labels, and no broad suspension sweep across unrelated mutable surfaces +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `SettingsRegistry`, `SettingsResolver`, `SettingsWriter`, existing workspace-setting audit path, `WorkspaceEntitlementResolver`, `WorkspaceSettings` as the current entitlement substrate reference, `App\Filament\System\Pages\Directory\ViewWorkspace`, `ManagedTenantOnboardingWizard`, `ReviewPackService`, `TenantReviewPackCard`, `ReviewRegister`, `TenantReviewResource`, `ReviewPackResource`, `CustomerReviewWorkspace`, `EvidenceSnapshotResource`, and `WorkspaceEntitlementBlockedException` +- **Shared abstractions reused**: `WorkspaceEntitlementResolver`, `WorkspacePlanProfileCatalog`, `SettingsResolver`, `SettingsWriter`, current workspace audit logging, `ReviewPackService`, `OperationUxPresenter`, `OperationRunLinks`, `Capabilities`, `PlatformCapabilities`, and existing Filament action/resource surfaces +- **New abstraction introduced? why?**: one bounded `WorkspaceCommercialLifecycleResolver` is justified because the existing entitlement resolver answers per-key entitlement truth but does not express one workspace-wide lifecycle posture with action-family outcomes, preserved read-only semantics, or system/admin messaging +- **Why the existing abstraction was sufficient or insufficient**: Spec 247 already provides canonical entitlement substrate truth and must remain the foundation. It is insufficient for `trial`, `grace`, `active_paid`, and `suspended_read_only` because those states cut across more than one entitlement key and need one central business-state explanation +- **Bounded deviation / spread control**: no page-local lifecycle conditionals and no second exception taxonomy by default; prefer reusing the existing blocked decision payload/catch path for review-pack actions unless implementation proves that the current class name or payload cannot carry lifecycle metadata cleanly + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes +- **Central contract reused**: existing shared review-pack OperationRun start UX through `ReviewPackService`, `OperationUxPresenter`, and `OperationRunLinks` +- **Delegated UX behaviors**: queued toast, run link, run-enqueued browser event, dedupe messaging, and existing terminal notifications remain unchanged when review-pack generation is allowed; lifecycle-blocked starts create no `OperationRun`, no queued DB notification, and no terminal notification +- **Surface-owned behavior kept local**: onboarding completion helper text, review-pack tooltips/disabled state, and suspended read-only explanation on history surfaces remain local projections of the central lifecycle decision +- **Queued DB-notification policy**: unchanged explicit opt-in only +- **Terminal notification path**: unchanged central lifecycle mechanism for existing review-pack runs only +- **Exception path**: none planned; lifecycle blocking must happen before `ReviewPackService` creates or reuses a `ReviewPack` or `OperationRun`, and the preferred later implementation is to extend the current blocked-decision payload rather than invent a second parallel business-state exception family + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: N/A +- **Platform-core seams**: workspace commercial lifecycle vocabulary, lifecycle rationale, action-family outcomes, system/admin messaging, and audit semantics +- **Neutral platform terms / contracts preserved**: `workspace`, `trial`, `grace`, `active paid`, `suspended / read-only`, `commercial state`, `review pack`, `managed tenant activation` +- **Retained provider-specific semantics and why**: none; review-pack generation stays provider-backed operationally, but the new lifecycle vocabulary remains platform-core and provider-neutral +- **Bounded extraction or follow-up path**: none + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: PASS - the slice adds workspace-owned business state, not new inventory or backup truth. +- Read/write separation: PASS - the only new write is a confirmation-protected, audited system-plane lifecycle mutation using existing workspace settings persistence. +- Graph contract path: PASS - no new Microsoft Graph path is introduced. +- Deterministic capabilities: PASS - admin-plane capabilities remain unchanged, and any new platform capability stays registry-backed. +- RBAC-UX: PASS - `/admin` and `/system` remain separated; wrong-plane and non-member access stay 404; member-without-capability stays 403; otherwise-authorized actors get a business-state block or warning instead of authorization failure. +- Workspace isolation: PASS - admin-plane contextual behavior still requires established workspace context. +- RBAC-UX destructive confirmation: PASS - the future system-plane state-change action must require confirmation and rationale. +- RBAC-UX global search: PASS - no new searchable resource or search scope is introduced. +- Tenant isolation: PASS - onboarding, review-pack, review history, evidence, and download surfaces remain tenant-safe. +- Run observability: PASS - review-pack generation keeps the existing `OperationRun` path when allowed, and blocked starts stop before run creation. +- OperationRun start UX: PASS - the plan preserves shared review-pack start UX and inserts lifecycle blocking before run creation. +- Ops-UX 3-surface feedback: PASS - existing feedback stays toast + progress surfaces + terminal notification only when a run exists. +- Ops-UX lifecycle: PASS - no new `OperationRun` lifecycle contract is introduced. +- Ops-UX summary counts: N/A - no `summary_counts` shape change is planned. +- Ops-UX guards: N/A - no new run guard family is planned in the planning slice. +- Ops-UX system runs: N/A - initiator-null behavior is unchanged. +- Automation: N/A - no new queued or scheduled workflow family is introduced. +- Data minimization: PASS - no payment payloads, account records, or provider secrets are introduced. +- Test governance (TEST-GOV-001): PASS - the plan stays in focused unit + feature lanes with explicit proof commands and limited fixture growth. +- Proportionality (PROP-001): PASS - persistence stays in existing settings rows, and the only new structural element is one bounded lifecycle overlay. +- No premature abstraction (ABSTR-001): PASS - no interface, registry, strategy system, or framework is planned; only one local resolver is added because multiple real surfaces already need the same lifecycle decision. +- Persisted truth (PERSIST-001): PASS - no new table or durable artifact is introduced. +- Behavioral state (STATE-001): PASS - `grace` and `suspended_read_only` create distinct action-family consequences immediately, and `trial` remains justified because it is part of the explicit platform-managed commercial posture and audit workflow even though its two in-scope gated families currently match `active_paid`. +- UI semantics (UI-SEM-001): PASS - the plan prefers direct mapping from lifecycle truth to helper text and badges instead of a new presentation framework. +- Shared pattern first (XCUT-001): PASS - system detail, onboarding, review-pack, and read-only history surfaces all reuse the existing substrate and shared run path first. +- Provider boundary (PROV-001): PASS - the new vocabulary is platform-core, not Microsoft-shaped. +- V1 explicitness / few layers (V1-EXP-001, LAYER-001): PASS - one explicit state family plus one thin overlay resolver is the narrowest viable shape. +- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): PASS - the plan keeps the whole lifecycle overlay in one coherent spec and includes proportionality review below. +- Badge semantics (BADGE-001): PASS - any future lifecycle badge must reuse shared badge semantics or stay plain text; no page-local color taxonomy is planned. +- Filament-native UI (UI-FIL-001): PASS - the slice extends existing Filament pages, resources, widgets, and the current system Blade page. +- Filament-native UI local Blade/Tailwind: PASS - the existing system Blade view remains the only custom-rendered surface in scope and must preserve current Filament visual language. +- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): PASS - existing system detail, guided onboarding, action family, and read-only viewer surface types remain intact. +- Decision-first operating model (DECIDE-001): PASS - system workspace detail is primary, onboarding/review-pack surfaces stay contextual, and read-only history/evidence pages remain tertiary evidence/diagnostics. +- Audience-aware disclosure (DECIDE-AUD-001 / OPSURF-001): PASS - system detail stays platform/support-facing, admin action gates stay operator-first, and suspended read-only pages keep customer-safe history access without raw platform diagnostics. +- UI/UX inspect model (UI-HARD-001): PASS - no duplicate inspect affordances are added. +- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): PASS - the plan keeps one system mutation action and existing onboarding/review-pack primary actions in place. +- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): PASS - labels remain narrow and billing-provider-free. +- UI/UX placeholder ban (UI-HARD-001): PASS - no empty action groups are planned. +- UI naming (UI-NAMING-001): PASS - primary labels stay `Change commercial state`, `Complete onboarding`, `Generate pack`, `Regenerate`, and `Export executive pack`. +- Operator surfaces (OPSURF-001): PASS - mutation scope remains explicit, and `/admin` surfaces only show contextual lifecycle truth. +- Operator surface page contract: PASS - the spec already defines the required surface contracts. +- Filament UI Action Surface Contract: PASS - touched surfaces already have contracts or exemptions; the plan preserves them while adding lifecycle truth. +- Filament UI UX-001 (Layout & IA): PASS - no new page shell or panel is introduced. +- Action-surface discipline (ACTSURF-001 / HDR-001): PASS - one system primary action and existing onboarding/review-pack action families remain the only primary mutations in scope. +- UI review workflow: PASS - guardrail, shared-family, and exception posture remain explicit in this plan. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Unit` for the bounded lifecycle overlay and behavior matrix; `Feature` for system-plane mutation, onboarding activation gating, review-pack start blocking, and preserved suspended read-only consumption +- **Affected validation lanes**: `fast-feedback`, `confidence` +- **Why this lane mix is the narrowest sufficient proof**: the business risk is deterministic decision ordering plus existing Filament/Livewire and service entry points. Browser or heavy-governance coverage would add cost without proving additional current-release risk for this bounded overlay. +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` +- **Fixture / helper / factory / seed / context cost risks**: limited to workspace, platform user, workspace member, onboarding draft, tenant, existing review pack, tenant review, and evidence snapshot fixtures +- **Expensive defaults or shared helper growth introduced?**: no - implementation should reuse existing factories and opt-in tenant/review/evidence helpers only +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: standard-native relief for system detail and onboarding; monitoring-state-page proof for no new run creation; shared-detail-family proof for preserved view/download access while suspended +- **Closing validation and reviewer handoff**: rerun the exact targeted commands above, verify 404 vs 403 vs business-state outcomes separately, verify system detail source labels remain consistent, verify blocked review-pack starts create no new `ReviewPack` or `OperationRun` and emit no queued or terminal notification, verify already queued or running review-pack runs continue unaffected after later suspension, and verify suspended workspaces still allow authorized review/evidence/download access +- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local growth +- **Review-stop questions**: does the state vocabulary stay narrow enough, does the system/admin split remain intact, does suspended read-only coverage avoid broad mutation-sweep scope, and does the blocked-decision transport avoid a second exception framework +- **Escalation path**: document-in-feature if only payload wording or helper reuse needs adjustment; follow-up-spec only if the overlay starts pulling unrelated mutable surfaces into suspension logic +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: the testing cost stays local to one overlay resolver and a small set of existing pages/services/resources; no new browser or heavy-governance harness is justified + +## Project Structure + +### Documentation (this feature) + +```text +specs/251-commercial-entitlements-billing-state/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── workspace-commercial-lifecycle-overlay.logical.openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md # Created later by /speckit.tasks, not by this plan step +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Exceptions/Entitlements/WorkspaceEntitlementBlockedException.php +│ ├── Filament/ +│ │ ├── Pages/ +│ │ │ ├── Reviews/CustomerReviewWorkspace.php +│ │ │ ├── Reviews/ReviewRegister.php +│ │ │ ├── Settings/WorkspaceSettings.php +│ │ │ └── Workspaces/ManagedTenantOnboardingWizard.php +│ │ ├── Resources/ +│ │ │ ├── EvidenceSnapshotResource.php +│ │ │ ├── EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php +│ │ │ ├── ReviewPackResource.php +│ │ │ ├── ReviewPackResource/Pages/ViewReviewPack.php +│ │ │ ├── TenantReviewResource.php +│ │ │ └── TenantReviewResource/Pages/ViewTenantReview.php +│ │ ├── System/Pages/Directory/ViewWorkspace.php +│ │ └── Widgets/Tenant/TenantReviewPackCard.php +│ ├── Models/WorkspaceSetting.php +│ ├── Services/ +│ │ ├── Entitlements/WorkspaceCommercialLifecycleResolver.php # likely new bounded overlay service +│ │ ├── Entitlements/WorkspaceEntitlementResolver.php +│ │ ├── ReviewPackService.php +│ │ └── Settings/ +│ │ ├── SettingsResolver.php +│ │ └── SettingsWriter.php +│ ├── Support/ +│ │ ├── Auth/Capabilities.php +│ │ ├── Auth/PlatformCapabilities.php +│ │ └── Settings/SettingsRegistry.php +├── resources/views/filament/system/pages/directory/view-workspace.blade.php +└── tests/ + ├── Feature/ + └── Unit/ +``` + +**Structure Decision**: Single Laravel/Filament application inside `apps/platform`, with one new bounded lifecycle overlay service and changes limited to existing settings persistence, system detail, onboarding, review-pack, and read-only review/evidence/download surfaces plus focused Pest coverage. + +## Likely Implementation Surfaces + +- `app/Support/Settings/SettingsRegistry.php` to register lifecycle-setting definitions and validation using the existing workspace settings infrastructure +- `app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` as the new bounded overlay, with `WorkspaceEntitlementResolver.php` remaining the canonical substrate provider +- `app/Support/Auth/PlatformCapabilities.php` and related platform authorization helpers for one dedicated commercial-lifecycle management capability +- `app/Filament/System/Pages/Directory/ViewWorkspace.php` and `resources/views/filament/system/pages/directory/view-workspace.blade.php` for read-only summary plus the confirmation-protected state-change action +- `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` for contextual lifecycle messaging and activation blocking before tenant mutation +- `app/Services/ReviewPackService.php`, `app/Filament/Widgets/Tenant/TenantReviewPackCard.php`, `app/Filament/Pages/Reviews/ReviewRegister.php`, `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/ReviewPackResource.php`, `app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`, and `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` for shared start gating and tooltip/disabled-state reuse +- Existing read-only consumption surfaces `CustomerReviewWorkspace.php`, `ViewTenantReview.php`, `ViewReviewPack.php`, `ViewEvidenceSnapshot.php`, and the current review-pack download path to prove suspended history/evidence access remains available under existing RBAC +- Focused unit and feature tests under `tests/Unit/Entitlements`, `tests/Feature/System/Directory`, `tests/Feature/Onboarding`, `tests/Feature/ReviewPack`, `tests/Feature/Reviews`, and `tests/Feature/Evidence` + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| One bounded `WorkspaceCommercialLifecycleResolver` | Two real gated behavior families plus preserved read-only consumption need one shared workspace-wide decision layered above existing entitlements | Page-local conditionals in onboarding, review-pack resources/widgets, and system detail would drift immediately and undermine business-state consistency | +| Four-state commercial lifecycle vocabulary | Platform operators need one auditable commercial posture that distinguishes trial, grace, active paid, and suspended/read-only on the single system decision surface | Three unlabeled booleans or ad hoc flags would either collapse grace into suspension or lose the explicit platform-side lifecycle state needed for support and audit | + +## Proportionality Review + +- **Current operator problem**: The repo can already answer per-key entitlement questions, but it cannot say in one place whether a workspace is currently trialing, in grace, fully active paid, or suspended/read-only, nor can it explain why onboarding and review-pack starts are blocked while history remains readable. +- **Existing structure is insufficient because**: `WorkspaceEntitlementResolver` and current workspace settings expose substrate truth only. They do not provide one workspace-wide lifecycle posture, one system-owned mutation path, or one action-family outcome that distinguishes lifecycle blocks from entitlement blocks and authorization failures. +- **Narrowest correct implementation**: Keep persistence inside existing `workspace_settings`, add only one four-state lifecycle family plus rationale, derive action-family outcomes in one bounded overlay service, mutate it from one system detail page, and apply it only to onboarding activation, review-pack starts, and preserved read-only history/evidence/download semantics. +- **Ownership cost created**: One new state vocabulary, one overlay service, one platform capability, cross-surface copy discipline, and focused lifecycle/read-only tests. +- **Alternative intentionally rejected**: A billing/subscription engine, customer-account model, payment-provider seam, or many local page booleans was rejected because the current release only needs a single workspace commercial overlay on top of the already-real entitlement substrate. +- **Release truth**: current-release truth. The four-state vocabulary is justified now because platform operators already need to set and audit those named postures, even though only `grace` and `suspended_read_only` introduce new blocked outcomes for the two in-scope action families in this slice. + +## Phase 0 — Research (output: `research.md`) + +See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/research.md` + +Goals: +- Confirm the narrowest reuse of the existing `entitlements` settings domain and audit path for lifecycle state and rationale. +- Confirm that one bounded overlay service can compose `WorkspaceEntitlementResolver` without creating a second commercial framework. +- Confirm that lifecycle mutation remains platform-only on the existing system workspace detail page and does not leak into `/admin` self-service. +- Confirm that review-pack start blocking happens before `ReviewPack` or `OperationRun` creation and can reuse the current blocked-decision transport. +- Confirm that suspended read-only preservation remains bounded to existing review, evidence, and generated-pack consumption surfaces instead of becoming a broad product-wide suspension sweep. + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +See: +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/data-model.md` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` + +Design focus: +- Persist `commercial_lifecycle_state` plus rationale through the existing `entitlements` settings domain instead of adding a new table or billing domain. +- Keep the overlay inside `App\Services\Entitlements` and let it compose `WorkspaceEntitlementResolver` rather than replacing it. +- Extend the existing system workspace detail page with a read-only lifecycle summary and one confirmation-protected `Change commercial state` action, while leaving `WorkspaceSettings` as substrate truth rather than a second mutation plane. +- Gate `ManagedTenantOnboardingWizard` completion from the central lifecycle decision after underlying entitlement truth is known. +- Gate review-pack `Generate pack`, `Regenerate`, and `Export executive pack` starts through `ReviewPackService` and current action surfaces, stopping before any `ReviewPack` or `OperationRun` write when the lifecycle blocks the action. +- Preserve `CustomerReviewWorkspace`, review detail, evidence detail, review-pack detail, and pack download access under current RBAC while suspended, and keep any broader mutable-surface suspension work explicitly out of scope. + +## Phase 1 — Agent Context Update + +After Phase 1 artifacts are generated, update Copilot context from the completed plan: + +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot` + +## Phase 2 — Implementation Outline (tasks created later by `/speckit.tasks`) + +- Register lifecycle state and rationale setting definitions under the existing `entitlements` settings domain and wire them into the current workspace-setting audit path. +- Add one bounded `WorkspaceCommercialLifecycleResolver` that composes underlying entitlement decisions and yields action-family outcomes plus suspended read-only allowances. +- Add one dedicated platform capability for commercial lifecycle management and enforce it on the system detail mutation action only. +- Extend `ViewWorkspace` plus its Blade view with current lifecycle state, affected behavior summary, and a confirmation-protected `Change commercial state` action. +- Gate onboarding completion in `ManagedTenantOnboardingWizard` using the shared lifecycle decision while preserving existing tenant operability checks and 404/403 semantics. +- Gate review-pack start surfaces and `ReviewPackService` using the shared lifecycle decision, preserving current queued-start UX when allowed and reusing the existing blocked-decision transport when blocked. +- Prove suspended read-only continuation by asserting existing review/evidence/download surfaces remain available under current RBAC while no new onboarding activation or review-pack run can start. +- Add focused Sail/Pest unit and feature coverage only. + +## Constitution Check (Post-Design) + +Re-check result: PASS. The design keeps Filament v5 + Livewire v4 compliance intact, leaves provider registration unchanged in `bootstrap/providers.php`, introduces no new globally searchable resource, keeps asset strategy unchanged, preserves strict `/admin` vs `/system` separation, layers one bounded lifecycle resolver above the existing entitlement substrate, and blocks review-pack starts before `OperationRun` creation rather than forking shared run UX. + +## Planning Readiness + +- Outcome: keep +- No unresolved clarification markers remain in the plan-phase artifacts. +- No application implementation is included in this planning step. +- The next repo-native step is `/speckit.tasks` for an implementation task breakdown, not code changes. + +## Implementation Close-Out + +- **Workflow outcome**: keep. +- **Implementation result**: one bounded commercial lifecycle overlay was implemented through existing workspace settings, one system-plane `Change commercial state` action, onboarding activation gating, review-pack start allow/warn/block semantics, and preserved suspended read-only review/evidence/download access. +- **Blocked-decision transport**: document-in-feature. The existing `WorkspaceEntitlementBlockedException` transport remains sufficient for review-pack blocked starts; no second business-state exception family was introduced. +- **Preserved read-only scope**: document-in-feature. Suspension stays bounded to onboarding activation and new review-pack starts in this spec; broader mutable-surface suspension remains out of scope. +- **Browser smoke path**: `/system/login` as `operator@tenantpilot.io`, `/system/directory/workspaces/1`, open `Change commercial state`, set `Trial` with rationale, confirm, observe updated lifecycle summary and notification follow-up, then restore `Active paid`. +- **Browser smoke result**: pass after fixing `WorkspaceResolver` to accept Livewire serialized workspace route parameters; the follow-up notification update no longer emits console errors or 404/419 markers. +- **Lane results**: targeted unit/support/system/onboarding/review-pack/read-only Pest lanes passed; dirty-only Pint passed; `git diff --check` passed. diff --git a/specs/251-commercial-entitlements-billing-state/quickstart.md b/specs/251-commercial-entitlements-billing-state/quickstart.md new file mode 100644 index 00000000..543fe227 --- /dev/null +++ b/specs/251-commercial-entitlements-billing-state/quickstart.md @@ -0,0 +1,109 @@ +# Quickstart: Commercial Entitlements and Billing-State Maturity + +**Date**: 2026-04-28 +**Branch**: `251-commercial-entitlements-billing-state` + +This quickstart is the intended reviewer flow after implementation. It stays bounded to the commercial lifecycle overlay described in the spec. + +## Prerequisites + +1. Start the local platform stack. + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d` +2. Ensure one platform user has directory visibility plus the dedicated commercial lifecycle management capability. +3. Ensure one workspace member can complete onboarding, one reporting operator can manage review packs, and one customer-safe or operator read-only actor can open review/evidence/download surfaces under current RBAC. +4. Seed or factory-create: + - one workspace with untouched lifecycle state + - one onboarding draft in that workspace + - one tenant with an existing review, evidence snapshot, and generated review pack + - one workspace already at or above the managed-tenant activation limit for substrate-block verification + +## Scenario 1: Change workspace commercial state from the system plane + +1. Open `/system/directory/workspaces/{workspace}` as the authorized platform user. +2. Confirm the page shows: + - current lifecycle state + - source label + - rationale and last-changed attribution + - affected behavior summary for onboarding and review-pack starts + - the underlying entitlement substrate summary for context +3. Use `Change commercial state` to move the workspace to `trial` with rationale. +4. Confirm the page updates immediately and the change is attributable. +5. Repeat with `grace`, `suspended_read_only`, and `active_paid`. +6. Confirm every explicit state change requires rationale, including a return to `active_paid`, and that the `Suspended / read-only` path also requires explicit confirmation. + +## Scenario 2: Gate onboarding activation with business-state truth + +1. Open `/admin/onboarding/{onboardingDraft}` for a workspace in `trial` or `active_paid`. +2. Confirm the completion step allows `Complete onboarding` when the underlying entitlement substrate also allows it. +3. Switch the same workspace to `grace` from the system plane. +4. Refresh the onboarding draft and confirm: + - the action remains visible for an otherwise authorized actor + - the step explains that expansion is frozen during grace + - no tenant activation occurs +5. Repeat with `suspended_read_only` and confirm the block message changes to read-only suspension semantics instead of a permission failure. + +## Scenario 3: Gate review-pack starts before any run is created + +1. Use a workspace in `trial` or `active_paid` where the underlying review-pack entitlement allows generation. +2. Trigger the current start family from: + - tenant dashboard review-pack card + - review register export action + - tenant review detail export action + - review-pack detail regenerate action +3. Confirm the existing queued-start UX remains unchanged when allowed. +4. Move the workspace to `grace`. +5. Confirm review-pack starts remain allowed with a grace warning. +6. Start one allowed review-pack action and leave the resulting work queued or running. +7. Move the workspace to `suspended_read_only`. +8. Confirm the already-created run remains visible and continues with the existing run UX. +9. Repeat the same start actions and confirm: + - each surface shows the same lifecycle-based reason + - no new `ReviewPack` row is created + - no new `OperationRun` row is created + - no queued or terminal review-pack notification is emitted for the blocked attempt + +## Scenario 4: Preserve read-only review, evidence, and generated-pack access while suspended + +1. Keep the workspace in `suspended_read_only`. +2. Open the current read-only consumption surfaces as an already-authorized actor: + - `CustomerReviewWorkspace` + - tenant review detail + - review-pack detail + - evidence snapshot detail + - current review-pack download link +3. Confirm: + - the pages still render + - already-generated review packs remain downloadable + - existing review/evidence history remains visible + - any read-only explanation stays calm and does not masquerade as 403 or 404 +4. Confirm the slice does not add broad new suspension behavior to unrelated mutable controls outside the spec boundary. + +## RBAC and Plane Semantics Checks + +1. Access lifecycle mutation from `/admin` and confirm there is no self-service control surface. +2. Access `/system/directory/workspaces/{workspace}` as a platform user lacking the dedicated lifecycle capability and confirm authorization is enforced without leaking admin-plane truth. +3. Access onboarding or review-pack surfaces as a non-member or wrong-plane actor and confirm 404. +4. Access the same surfaces as an established-scope actor lacking the relevant capability and confirm 403. +5. Access the action as an otherwise authorized actor whose workspace lifecycle blocks the action and confirm a truthful business-state block instead of 403 or 404. + +## Targeted Validation Commands + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php + +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +## Out of Scope Confirmations + +While validating this slice, confirm that the implementation does not add or imply: + +- payment-provider credentials, invoices, checkout, taxes, or public pricing UI +- customer-account, subscription, or contract models +- automated expiry/reminder/renewal logic +- a second admin-plane commercial settings surface +- a broad suspension engine across unrelated mutable product surfaces \ No newline at end of file diff --git a/specs/251-commercial-entitlements-billing-state/research.md b/specs/251-commercial-entitlements-billing-state/research.md new file mode 100644 index 00000000..eacd751e --- /dev/null +++ b/specs/251-commercial-entitlements-billing-state/research.md @@ -0,0 +1,84 @@ +# Research: Commercial Entitlements and Billing-State Maturity + +**Date**: 2026-04-28 +**Branch**: `251-commercial-entitlements-billing-state` + +## Decision 1: Persist lifecycle truth inside the existing `entitlements` settings domain + +- **Decision**: Store the workspace commercial lifecycle overlay through explicit `WorkspaceSetting` keys in the existing `entitlements` domain, conceptually `commercial_lifecycle_state` plus `commercial_lifecycle_reason`. +- **Rationale**: Spec 247 already proved that workspace-owned commercial truth belongs in the existing workspace settings infrastructure. Reusing that path keeps audit behavior, validation, and source-of-truth ownership consistent without inventing a billing/account model or a second persistence family. +- **Alternatives considered**: + - New `subscriptions`, `billing_states`, or `customer_accounts` tables: rejected because the spec explicitly forbids broad billing/account scope. + - A separate `commercial` settings domain: rejected because the new state is an overlay on the already-real entitlement substrate, not a second independent settings family. + +## Decision 2: Add one bounded lifecycle overlay service above `WorkspaceEntitlementResolver` + +- **Decision**: Introduce one bounded `WorkspaceCommercialLifecycleResolver` in `App\Services\Entitlements` that composes `WorkspaceEntitlementResolver` instead of replacing it. +- **Rationale**: The underlying entitlement resolver remains canonical for plan-profile defaults, override values, and per-key allow/block truth. The new feature needs one additional workspace-wide layer that can answer lifecycle state, lifecycle rationale, and action-family outcomes across onboarding, review-pack starts, and preserved read-only history access. +- **Alternatives considered**: + - Extend `WorkspaceEntitlementResolver` until it also owns lifecycle posture: rejected because that would blur substrate truth with the new overlay and make future review of state ordering harder. + - Local page/service conditionals in onboarding, review-pack resources, and system detail: rejected because they would drift immediately. + +## Decision 3: Keep system-plane mutation on the existing workspace detail page only + +- **Decision**: Make `/system/directory/workspaces/{workspace}` the only mutation surface for lifecycle state changes, with inspection plus a confirmation-protected `Change commercial state` action. +- **Rationale**: The spec requires platform-managed lifecycle mutation. The existing system workspace detail page already exposes commercial truth read-only and is the narrowest platform context that can show state, rationale, and audit attribution without creating a second control plane. +- **Alternatives considered**: + - Add lifecycle mutation to `/admin/settings/workspace`: rejected because the slice must not become a self-service workspace-admin commercial control surface. + - Create a dedicated system commercial page/resource: rejected because the existing workspace detail page already anchors the platform/support workflow. + +## Decision 4: Preserve explicit business-state versus authorization semantics + +- **Decision**: Keep non-member and wrong-plane access as 404, keep established-scope capability denial as 403, and treat lifecycle blocking or warnings as business-state results for otherwise authorized actors. +- **Rationale**: This is the main operator value of the slice. The commercial lifecycle overlay must explain why an action is blocked without pretending the actor lacks scope or permission. +- **Alternatives considered**: + - Hide blocked actions entirely: rejected because it would erase the commercial explanation the feature exists to provide. + - Return 403 for lifecycle blocks: rejected because it would conflate business state with authorization. + +## Decision 5: Review-pack lifecycle blocking must happen before `ReviewPack` or `OperationRun` creation + +- **Decision**: Reuse `ReviewPackService` as the hard enforcement boundary and block lifecycle-restricted starts before any `ReviewPack` or `OperationRun` write occurs. +- **Rationale**: Current review-pack start surfaces already converge on `ReviewPackService`. Blocking at the service boundary prevents UI-surface bypass and preserves the shared OperationRun start UX for allowed actions. +- **Alternatives considered**: + - UI-only disabling on each widget/resource/page action: rejected because it would not protect direct action execution. + - A new review-pack lifecycle queue/framework: rejected because the slice changes eligibility only, not run orchestration. + +## Decision 6: Reuse the existing blocked-decision transport if it can carry lifecycle metadata cleanly + +- **Decision**: Prefer reusing `WorkspaceEntitlementBlockedException` and extending its decision payload for lifecycle blocks, rather than introducing a second parallel business-state exception family. +- **Rationale**: Review-pack widgets/resources already catch `WorkspaceEntitlementBlockedException` and project its `block_reason` into user-visible feedback. Reusing that transport keeps the change narrow unless implementation proves the class name or payload shape is too substrate-specific. +- **Alternatives considered**: + - New `WorkspaceCommercialLifecycleBlockedException`: rejected for now because it would widen changes across all review-pack action surfaces without proving extra value. + - Plain string returns without a shared decision payload: rejected because the UI surfaces already consume structured block context. + +## Decision 7: Preserve suspended read-only access by leaving existing history/evidence/download routes outside the new gate + +- **Decision**: Keep `CustomerReviewWorkspace`, `ViewTenantReview`, `ViewReviewPack`, `ViewEvidenceSnapshot`, and current review-pack download access outside the new lifecycle start gate, while allowing them to show a calm read-only explanation when helpful. +- **Rationale**: The feature promise is not "suspend everything." It is "block future starts while preserving safe existing history." Existing view/download routes already encode current RBAC and redaction semantics and are the narrowest place to preserve that truth. +- **Alternatives considered**: + - Broad product-wide suspension of all mutable controls: rejected because the spec explicitly forbids a broad suspension engine. + - No plan for preserved read access: rejected because suspension would otherwise appear as total lockout and break the evidence/history requirement. + +## Decision 8: Keep the four-state vocabulary, but justify it narrowly + +- **Decision**: Keep exactly four lifecycle states: `trial`, `grace`, `active_paid`, and `suspended_read_only`. +- **Rationale**: The spec requires these named postures, and platform operators need to set and audit them explicitly from one system surface. `grace` and `suspended_read_only` have immediate distinct action-family consequences. `trial` remains in scope because the platform/support workflow and audit trail need to distinguish temporary non-paid posture from steady active paid posture now, even though both allow the two in-scope gated behavior families. +- **Alternatives considered**: + - Collapse to three states by removing `trial`: rejected because it would erase a required current-release commercial posture and force later renaming/migration when trial lifecycle work grows. + - Persist only booleans like `is_suspended` and `is_in_grace`: rejected because that would not yield one clear operator-facing commercial state. + +## Decision 9: Prove the slice with focused unit and feature lanes only + +- **Decision**: Use one unit family for lifecycle resolution and focused feature tests for system mutation, onboarding gating, review-pack no-run blocking, and suspended read-only consumption. +- **Rationale**: The primary risk is correctness of decision ordering and bounded surface behavior, not browser layout or heavy orchestration. +- **Alternatives considered**: + - Browser tests: rejected because no browser-only interaction risk is introduced in the planning slice. + - Heavy-governance suite expansion: rejected because the scope is feature-local and uses existing surfaces. + +## Decision 10: Leave panels, assets, and global search unchanged + +- **Decision**: Do not add new panels, provider registration changes, global-search resources, or Filament assets as part of this slice. +- **Rationale**: The feature is a business-state overlay inside existing admin and system surfaces. Infrastructure changes would widen scope without helping the current release. +- **Alternatives considered**: + - New commercial panel: rejected because `/system` detail already anchors the platform workflow. + - Asset-backed custom commercial UI: rejected because current Filament components and the existing Blade detail view are sufficient. \ No newline at end of file diff --git a/specs/251-commercial-entitlements-billing-state/spec.md b/specs/251-commercial-entitlements-billing-state/spec.md new file mode 100644 index 00000000..0f16fc90 --- /dev/null +++ b/specs/251-commercial-entitlements-billing-state/spec.md @@ -0,0 +1,332 @@ +# Feature Specification: Commercial Entitlements and Billing-State Maturity + +**Feature Branch**: `251-commercial-entitlements-billing-state` +**Created**: 2026-04-28 +**Status**: Draft +**Input**: User description: "Commercial lifecycle follow-up on top of the already-real Spec 247 entitlement substrate, with one central workspace lifecycle resolution, bounded lifecycle states, two real gated behaviors, explicit read-only suspension semantics, and audited state changes without expanding into a billing engine." + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: TenantPilot already resolves plan-profile entitlements for a workspace, but it still lacks one central commercial lifecycle state that explains whether the workspace is in trial, grace, normal paid use, or suspended/read-only posture. +- **Today's failure**: Operators can hit blocked onboarding or reporting actions without one consistent business-state explanation, and a future suspension or grace posture would otherwise be implemented as scattered local conditionals or mistaken as RBAC denial. +- **User-visible improvement**: Platform operators can set one auditable workspace commercial lifecycle state, and tenant/workspace operators then see a truthful allow, warn, or read-only message directly at onboarding and review-pack action surfaces without losing safe access to existing history and evidence. +- **Smallest enterprise-capable version**: Add one platform-managed workspace commercial lifecycle overlay on top of the existing entitlement substrate, resolve four bounded lifecycle states, gate managed-tenant onboarding activation plus review-pack start actions from that central decision, and preserve safe read-only access to existing review/evidence history while suspended. +- **Explicit non-goals**: No payment providers, invoicing, taxes, accounting, checkout, public pricing, website work, customer-account modeling, subscription engine, automated renewal reminders, broad entitlement spread, or customer self-service lifecycle management. +- **Permanent complexity imported**: One bounded lifecycle state family, one small central lifecycle resolution layer on top of the existing entitlement substrate, one platform-side state change surface with audit, and focused unit plus feature coverage. +- **Why now**: This directly extends real repo truth from Spec 247 and `WorkspaceEntitlementResolver`, so it is implementation-ready as a narrow follow-up. Localization remains a broader missing foundation, and external support-desk handoff still lacks a concrete external target. +- **Why not local**: The same commercial posture must drive system support visibility, onboarding activation, review-pack generation, and suspended read-only access rules. Local page checks would drift immediately and recreate the current manual explanation problem. +- **Approval class**: Core Enterprise +- **Red flags triggered**: New state axis, foundation-sounding commercial theme, and multi-surface touchpoint. Defense: this slice is limited to one overlay on top of an existing resolver, one platform mutation surface, two already-real gated behaviors, and explicit read-only preservation instead of a broader billing platform. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace +- **Primary Routes**: + - `/system/directory/workspaces/{workspace}` for platform-side inspection and lifecycle state change + - `/admin/onboarding/{onboardingDraft}` for managed-tenant onboarding activation + - `/admin/reviews` plus existing tenant review detail, tenant dashboard, and review-pack registry/detail surfaces for `Generate pack`, `Regenerate`, and `Export executive pack` + - existing read-only review, evidence, and generated-pack consumption surfaces that must remain available while suspended/read-only +- **Data Ownership**: Commercial lifecycle state remains workspace-owned truth and is stored through the existing workspace settings infrastructure. Existing plan profiles and entitlement decisions from Spec 247 remain the underlying workspace-owned substrate. Tenant-owned review packs, evidence snapshots, review history, and onboarding records stay tenant-owned and are not remodeled by this slice. +- **RBAC**: Platform users with directory visibility plus a dedicated commercial lifecycle management capability may inspect and change state on `/system`. Workspace or tenant members keep their existing onboarding and review-pack capabilities on `/admin`, but lifecycle state is a business-state overlay rather than a self-service setting. Non-members and wrong-plane actors continue to receive 404. Members missing capability continue to receive 403. Members with the required capability but blocked by lifecycle state receive a truthful business-state block instead of an authorization failure. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: N/A - this slice does not introduce a new tenantless collection or cross-tenant list. +- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace and tenant isolation remain authoritative. The lifecycle overlay never reveals tenant-owned history or artifacts outside the already-authorized workspace and tenant scope. + +## 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 +- **Interaction class(es)**: status messaging, action gating, system detail controls, operation-start blocking, evidence/report viewers +- **Systems touched**: existing workspace settings persistence, existing workspace entitlement resolution, system workspace detail view, onboarding activation gate, review-pack generation entry family, audit logging, and existing read-only review/evidence/download surfaces +- **Existing pattern(s) to extend**: existing workspace entitlement resolver and summary pattern, existing workspace-setting audit path, existing review-pack start UX, existing onboarding activation gate, and existing system detail summary surfaces +- **Shared contract / presenter / builder / renderer to reuse**: the current workspace entitlement resolution path and its audit-backed settings persistence remain the canonical substrate; this slice adds one bounded commercial lifecycle decision layer on top rather than a second parallel commercial framework +- **Why the existing shared path is sufficient or insufficient**: The current entitlement substrate is already sufficient for plan defaults, overrides, and per-key allow/block decisions. It is insufficient for one workspace-wide lifecycle posture that can say "expansion frozen" or "read-only suspended" consistently across multiple surfaces. +- **Allowed deviation and why**: none. No surface may invent local lifecycle labels, local business-state copy, or page-specific suspension rules. +- **Consistency impact**: State labels, source labels, block reasons, and read-only explanations must mean the same thing on the system workspace page, onboarding completion step, review-pack start actions, and preserved read-only review/evidence surfaces. +- **Review focus**: Reviewers must verify that all in-scope surfaces consume one shared lifecycle decision, that lifecycle overlay semantics do not expand access beyond current entitlements, and that suspended read-only messaging does not drift across surfaces. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: yes +- **Shared OperationRun UX contract/layer reused**: existing review-pack queued-start, `Open operation`, and canonical run-link behavior remain unchanged when lifecycle state allows the start action +- **Delegated start/completion UX behaviors**: queued toast, `Open operation` link, dedupe behavior, and terminal lifecycle feedback stay on the existing review-pack path when allowed. A lifecycle block stops earlier and produces no queued-start feedback because no run is created. +- **Local surface-owned behavior that remains**: local surfaces only render lifecycle state, blocked reason, and the safe next step. They do not replace the existing review-pack run UX. +- **Queued DB-notification policy**: unchanged +- **Terminal notification path**: central lifecycle mechanism for existing review-pack runs only +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +N/A - no shared provider/platform boundary is changed. Commercial lifecycle state is platform-core workspace truth and must remain provider-neutral even when it gates provider-backed review-pack workflows. + +## 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 | +|---|---|---|---|---|---|---| +| Platform workspace commercial-state controls | yes | Native Filament system detail page | detail summary, header actions, status messaging | detail page, header action, summary card | no | Single platform mutation surface only | +| Managed tenant onboarding activation gate | yes | Native Filament wizard | action gating, helper text, business-state callout | completion step, confirmation action | no | Reuses the existing activation step | +| Review-pack generation entry family | yes | Native Filament widget/resource/page actions | operation-start gating, helper text, state badges | widget action, detail action, list/header action | no | Only `Generate pack`, `Regenerate`, and `Export executive pack` are in scope | +| Existing read-only review, evidence, and generated-pack consumption surfaces | yes | Native Filament detail and download surfaces | evidence/report viewers, detail messaging | detail page, download action, read-only summary | no | No new routes; the slice only preserves safe read-only availability during suspension | + +## 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 | +|---|---|---|---|---|---|---|---| +| Platform workspace commercial-state controls | Primary Decision Surface | Platform operator decides whether a workspace should remain trial, move into grace, return to active paid, or become suspended/read-only | Current state, rationale, affected action families, and last changed attribution | Existing entitlement summary and related workspace diagnostics | Primary because this is the one place where commercial posture is intentionally changed | Follows platform support/commercial workflow rather than customer admin navigation | Prevents founders or support staff from reconstructing state from ad hoc notes and blocked actions | +| Managed tenant onboarding activation gate | Primary Decision Surface | Workspace operator decides whether the current tenant may be activated now | Lifecycle state, whether activation is allowed, and the business-state reason when blocked | Existing onboarding verification and readiness diagnostics remain secondary | Primary because onboarding completion is the actual high-impact mutation point | Keeps the commercial decision inside the activation workflow | Removes the need to ask support whether a block is about permissions or billing state | +| Review-pack generation entry family | Secondary Context Surface | Reporting operator decides whether to start, retry, or export a review pack from the current tenant or review context | Lifecycle state, whether the start action is blocked, and the safe fallback when suspended/read-only | Existing run state, artifact truth, and review history remain secondary | Not primary because the family exists to continue reporting/review workflows, not to manage commercial posture itself | Stays inside existing report-generation workflows | Avoids a second support lookup just to understand why generation is blocked | +| Existing read-only review, evidence, and generated-pack consumption surfaces | Tertiary Evidence / Diagnostics Surface | Customer-safe or operator read-only consumer verifies existing history while the workspace is suspended/read-only | Existing history, evidence, and generated pack availability plus a calm read-only explanation | Raw provider or support diagnostics remain secondary and capability-gated | Not primary because these surfaces answer "what history is still safe to read" rather than "what state should change" | Preserves evidence-first review consumption instead of forcing new export workarounds | Prevents suspended workspaces from looking completely unavailable when history should still be readable | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Platform workspace commercial-state controls | support-platform, operator-platform | Current lifecycle state, rationale, last changed attribution, and affected behavior summary | Existing workspace entitlement summary and tenant counts | No raw settings payload or internal debug data by default | `Change commercial state` | Raw settings rows and internal debugging remain hidden | The page states the lifecycle blocker once and reuses the same labels later rather than restating them differently | +| Managed tenant onboarding activation gate | operator-MSP | Activation allowed/blocked, current lifecycle state, and why the block is business-state rather than permission-state | Existing readiness and verification diagnostics already on the wizard | No support/raw payloads on the default path | `Complete onboarding` when allowed, otherwise `Request commercial review` | Deeper commercial diagnostics stay off the onboarding surface | The step shows one lifecycle explanation and does not restate the whole workspace commercial profile | +| Review-pack generation entry family | operator-MSP | Start action availability, current lifecycle state, and the safe fallback when generation is blocked | Existing run state and artifact status | No raw support diagnostics on start surfaces | `Generate pack`, `Regenerate`, or `Export executive pack` when allowed; otherwise `View current pack` | System-only lifecycle controls stay off these surfaces | One shared lifecycle reason is reused across all in-scope start actions | +| Existing read-only review, evidence, and generated-pack consumption surfaces | customer-read-only, operator-MSP | What history remains available and why the workspace is read-only rather than fully inaccessible | Existing review history and artifact provenance | Support/raw details remain collapsed or gated | `View current review` or `Download current pack` | Any mutation affordance stays blocked in suspended/read-only posture | The read-only explanation appears once and later sections add evidence rather than repeating the same blocker | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Platform workspace commercial-state controls | System / Detail / Diagnostics | Read-only detail with bounded mutation action | Change the workspace lifecycle state | Dedicated workspace detail page | forbidden | Existing admin-workspace and related navigation stay secondary | `Change commercial state` contains the high-risk `Suspended / read-only` path with explicit confirmation | `/system/directory/workspaces` | `/system/directory/workspaces/{workspace}` | Platform workspace identity plus current lifecycle state | Commercial lifecycle | Current state, rationale, and affected behaviors | Acceptable detail-surface exception because mutation stays bounded to one header action on the detail page | +| Managed tenant onboarding activation gate | Workflow / Guided action entry | Onboarding completion step | Complete onboarding or stop because commercial state blocks expansion | In-page completion step | forbidden | Existing back-navigation and tenant links stay secondary | Existing `Cancel draft` and `Delete draft` remain the only destructive actions | `/admin/onboarding` | `/admin/onboarding/{onboardingDraft}` | Workspace context plus current tenant | Onboarding commercial state | Activation allowed or blocked and why | Existing wizard exception remains valid | +| Review-pack generation entry family | Contextual action family | Widget/resource/page start actions | Start, retry, or export a review pack when allowed | Explicit action on the current tenant or review context | mixed - existing registry rows may still open detail, but start actions remain explicit | Existing `View` and `Download` stay secondary and outside the blocked start gate | Existing destructive actions remain out of scope and keep current placement | `/admin/reviews` plus existing tenant review-pack collection surfaces | Existing tenant review detail and review-pack detail surfaces | Active workspace, active tenant, review or pack context | Review-pack generation | Start allowed or blocked, and the safe read-only fallback | Grouped-action family exception is documented here so all start actions share one gate | +| Existing read-only review, evidence, and generated-pack consumption surfaces | Detail / Report viewer / Download | Read-only detail and artifact consumption | View history or download an already-generated pack | Existing review or pack detail page | allowed where the current collection already opens detail | Supporting navigation remains secondary | none | Existing review and review-pack collections | Existing review, evidence, and review-pack detail routes | Active workspace, active tenant, current artifact or review | Review history / Generated pack | Safe read-only availability during suspension | No new surface type introduced | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Platform workspace commercial-state controls | Platform commercial or support operator | Decide the current commercial posture of a workspace | System detail page | What lifecycle state should this workspace be in now? | State, rationale, affected behaviors, and last changed attribution | Existing entitlement summary and workspace diagnostics | commercial lifecycle, entitlement substrate | TenantPilot only | Change commercial state | Set suspended/read-only | +| Managed tenant onboarding activation gate | Workspace owner or manager completing onboarding | Decide whether the current tenant can be activated now | Guided workflow step | Can I activate this tenant under the current commercial posture? | Current lifecycle state, whether activation is allowed, and the block reason when not | Existing verification and bootstrap detail | onboarding readiness, commercial lifecycle, entitlement availability | TenantPilot only for activation state | Complete onboarding | Cancel draft, Delete draft | +| Review-pack generation entry family | Workspace manager or reporting operator | Decide whether a new pack run may start now | Contextual start-action family | Can I start, retry, or export a pack from this context? | Current lifecycle state, whether the start action is blocked, and the safe fallback | Existing run state, review status, and artifact truth | commercial lifecycle, entitlement availability, run state, artifact status | TenantPilot only until the existing run starts | Generate pack, Regenerate, Export executive pack, View current pack | Existing destructive actions remain unchanged and out of scope | +| Existing read-only review, evidence, and generated-pack consumption surfaces | Customer-safe reader or workspace operator | Consume already-generated history safely while the workspace is read-only | Read-only detail and download surfaces | What history can I still read or download safely? | Existing review/evidence/download truth plus a calm read-only explanation | Raw provider diagnostics and support-only detail | commercial lifecycle, artifact availability | none | View current review, Download current pack | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: yes - one workspace-owned commercial lifecycle state becomes current-release business truth, but it is stored through existing workspace settings rather than a new table +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: yes - one bounded lifecycle resolution layer on top of the existing entitlement substrate +- **New enum/state/reason family?**: yes - the four-state lifecycle family (`trial`, `grace`, `active_paid`, `suspended_read_only`) +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Support and operators cannot truthfully explain whether a workspace is in a normal commercial state, an expansion freeze, or a read-only suspension without reconstructing the answer from scattered surface behavior. +- **Existing structure is insufficient because**: Spec 247 gives per-key entitlement truth, but it does not provide one workspace-wide lifecycle posture that can say "activation blocked but reading is still safe" or "new runs blocked while history remains available." +- **Narrowest correct implementation**: Keep persistence inside the existing workspace settings infrastructure, add one small state family and one shared resolution layer, mutate it from one system detail page, and apply it only to two already-real start behaviors plus suspended read-only preservation. +- **Ownership cost**: One state vocabulary, one additional decision layer, cross-surface copy discipline, and focused tests for state transitions plus allowed/blocked behavior. +- **Alternative intentionally rejected**: A new subscription/customer-account model or many per-surface lifecycle flags was rejected because the repo has no current billing domain and the smallest safe slice only needs one central commercial posture. +- **Release truth**: current-release truth with later follow-up candidates for automation, billing integration, and broader lifecycle-aware entitlement spread + +### 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**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: Unit coverage proves default state resolution, state precedence over existing entitlements, and state-to-behavior mapping. Focused feature coverage proves platform mutation, audit logging, onboarding blocks, review-pack start blocks, and preserved read-only access without expanding into browser or heavy-governance lanes. +- **New or expanded test families**: one bounded lifecycle resolver unit family plus focused extensions to the existing system detail, onboarding, review-pack, and preserved read-only feature families +- **Fixture / helper cost impact**: Add only workspace, platform user, workspace member, onboarding draft, active tenant count, existing review pack, and existing evidence/history fixtures required to prove the state consequences. Avoid payment-provider mocks, browser harnesses, or new heavy support fixtures. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: standard-native-filament, shared-detail-family, monitoring-state-page +- **Standard-native relief or required special coverage**: Standard Filament feature coverage is sufficient for the system detail mutation surface and onboarding gate. Review-pack gating still needs monitoring-state assertions to prove blocked starts create no run, while suspended read-only preservation needs one detail/download assertion on existing artifact surfaces. +- **Reviewer handoff**: Reviewers must confirm that lifecycle blocks remain distinct from 404 and 403 outcomes, that source labels stay consistent on the system detail surface, that `grace` and `suspended_read_only` do not collapse into one behavior, that blocked review-pack starts create no queued or terminal notification, that already queued or running review-pack runs remain unaffected by later suspension, and that existing read-only history/download access remains available under current RBAC. +- **Budget / baseline / trend impact**: low feature-local increase only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` + +## Scope Boundaries *(required for this slice)* + +### In Scope + +- One central workspace commercial lifecycle overlay with exactly four states: `trial`, `grace`, `active_paid`, and `suspended_read_only` +- One platform-managed lifecycle change path with rationale and audit, persisted through the existing workspace settings infrastructure +- One shared lifecycle resolution path layered on top of the existing Spec 247 entitlement substrate +- Lifecycle gating of managed-tenant onboarding activation +- Lifecycle gating of review-pack `Generate pack`, `Regenerate`, and `Export executive pack` entry points +- Suspended/read-only preservation of authorized review history, evidence, and already-generated review-pack consumption +- Explicit business-state messaging that distinguishes lifecycle blocks from RBAC failures + +### Non-Goals + +- Payment providers, invoices, taxes, accounting, checkout, public pricing, website work, and payment failure workflows +- New customer-account, subscription, contract, or offer models +- Automated timers, expiries, reminders, or scheduled state transitions +- Customer self-service state changes from the workspace admin plane +- Broad entitlement expansion across seats, exports, retention, support SLAs, or unrelated feature flags +- Broad suspension logic across every mutable surface in the product +- A second commercial control plane outside the existing system workspace detail flow + +## Assumptions + +- Spec 247 remains the canonical entitlement substrate. Commercial lifecycle state is an overlay that can warn or restrict, not a replacement for plan-profile and per-key entitlement truth. +- Commercial lifecycle mutation is platform-managed in this slice. Workspace and tenant operators may observe the resulting state where it matters, but they do not change it themselves. +- If no explicit lifecycle state has been set for a workspace, the system resolves to `active_paid` so that current repo behavior stays unchanged until a platform operator intentionally selects a different state. +- `grace` is intentionally narrower than `suspended_read_only`: it freezes new managed-tenant activation but continues to allow existing review-pack start behavior when the underlying entitlement substrate still allows it. +- `suspended_read_only` preserves existing review/evidence/download access under current RBAC and redaction rules, but blocks new onboarding activation and new review-pack start actions. + +## Risks + +- `grace` and `suspended_read_only` can drift into near-duplicates if blocked-action copy and tests do not keep their consequences distinct. +- A later customer-account or billing source could require revisiting how manual lifecycle transitions are sourced, even though that broader domain is intentionally out of scope here. +- A future admin-plane commercial settings surface could confuse ownership if it appears without preserving platform-only mutation authority. +- Mid-flight review-pack runs created before a workspace becomes suspended could create confusion if the product does not clearly state that this slice only blocks future starts. + +## Deferred Adjacent Candidates + +- **Localization v1** remains a separate, broader foundation candidate because it requires cross-product locale resolution and copy governance beyond this bounded commercial lifecycle slice. +- **External Support Desk / PSA Handoff** remains a separate candidate because repo docs still do not define one concrete external desk target to hand off into. +- Broader billing lifecycle automation, reminders, and external billing-source integration stay deferred until a real account and payment domain exists in repo truth. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Set one workspace commercial lifecycle state centrally (Priority: P1) + +As a platform commercial or support operator, I want to set a workspace's current commercial lifecycle state once so downstream product behavior follows one audited source of truth instead of local exceptions. + +**Why this priority**: Without one central lifecycle state, every later gate or support explanation would duplicate commercial truth and drift away from the already-real entitlement substrate. + +**Independent Test**: Open the existing system workspace detail surface, change the lifecycle state with rationale, and verify that the new state is visible there and auditable without touching onboarding or reporting flows. + +**Acceptance Scenarios**: + +1. **Given** a workspace has no explicit commercial lifecycle state, **When** an authorized platform operator sets it to `trial` with rationale, **Then** the workspace resolves to `trial`, the change is auditable, and the system detail surface shows the new state and rationale. +2. **Given** a workspace is currently in `grace`, **When** an authorized platform operator changes it to `suspended_read_only`, **Then** the previous state is replaced, the new state is auditable, and later gated surfaces consume the new state. +3. **Given** a workspace is in `suspended_read_only`, **When** an authorized platform operator returns it to `active_paid`, **Then** future gated actions again use the normal underlying entitlement substrate instead of the suspended overlay. + +--- + +### User Story 2 - Truthfully block tenant activation when lifecycle state freezes expansion (Priority: P1) + +As an authorized workspace operator, I want the onboarding completion step to tell me whether the tenant may be activated under the current commercial lifecycle state so I can distinguish business-state blocking from permissions or onboarding readiness problems. + +**Why this priority**: Managed-tenant activation is the highest-risk first-slice mutation and the clearest place where a grace or suspended posture must stop expansion without ambiguity. + +**Independent Test**: Seed workspaces in `trial`, `active_paid`, `grace`, and `suspended_read_only`, open the existing onboarding completion step, and verify that the same action becomes allowed or blocked with the correct business-state explanation before any activation mutation happens. + +**Acceptance Scenarios**: + +1. **Given** a workspace is `trial` or `active_paid` and the existing entitlement substrate allows activation, **When** an authorized operator reaches the onboarding completion step, **Then** the step allows completion and no lifecycle block is shown. +2. **Given** a workspace is in `grace`, **When** the same operator reaches the completion step, **Then** the action remains visible but blocked with a business-state explanation that new managed-tenant activation is frozen during grace. +3. **Given** a workspace is in `suspended_read_only`, **When** the operator reaches the same step, **Then** activation is blocked before any tenant mutation occurs and the step explains that the workspace is read-only rather than lacking permission. + +--- + +### User Story 3 - Block new review-pack starts while preserving safe historical access (Priority: P2) + +As a reporting operator or customer-safe reader, I want new review-pack start actions to obey the current commercial lifecycle state while already-generated history remains safely readable so suspension does not erase needed evidence. + +**Why this priority**: Review-pack generation already exists on multiple real surfaces, and suspension is only trustworthy if it blocks new starts consistently while preserving safe access to history and already-generated evidence. + +**Independent Test**: Seed a workspace with an existing generated pack and history, switch it to `suspended_read_only`, verify that `Generate pack`, `Regenerate`, and `Export executive pack` stop before any new run or artifact is created, that blocked starts emit no queued or terminal review-pack notification, that already queued or running review-pack work continues unchanged, and then confirm that authorized readers can still view or download the already-generated artifacts. + +**Acceptance Scenarios**: + +1. **Given** a workspace is `active_paid` or `trial` and the existing review-pack entitlement allows generation, **When** an authorized operator starts `Generate pack`, `Regenerate`, or `Export executive pack`, **Then** the current review-pack flow continues unchanged. +2. **Given** a workspace is in `grace` and the underlying review-pack entitlement allows generation, **When** an authorized operator starts the same action, **Then** the action remains allowed with a grace warning and without blocking the run. +3. **Given** a workspace is in `suspended_read_only`, **When** an authorized operator attempts `Generate pack`, `Regenerate`, or `Export executive pack`, **Then** the action is blocked before any new `ReviewPack` or `OperationRun` is created and no queued or terminal review-pack notification is emitted for the blocked attempt. +4. **Given** a review-pack run was already created while the workspace lifecycle state still allowed it, **When** the workspace later moves to `suspended_read_only`, **Then** the existing queued or running review-pack work may complete unchanged because this slice only blocks future start attempts. +5. **Given** a workspace is in `suspended_read_only` and already has generated review packs, evidence, or review history, **When** an authorized reader opens or downloads those existing artifacts, **Then** the existing read-only access continues under current RBAC and redaction rules. + +### Edge Cases + +- A workspace with no explicit lifecycle state must still resolve deterministically to `active_paid` so current Spec 247 behavior does not change accidentally. +- If the lifecycle state allows a behavior but the underlying entitlement substrate blocks it, the underlying entitlement block still applies and must remain distinguishable from lifecycle blocking. +- If the lifecycle state becomes `suspended_read_only` while a review-pack run is already queued or running, the existing run may complete; the new state only blocks future start attempts in this slice. +- A workspace member who lacks the relevant onboarding or review-pack capability must still receive 403 even when the workspace lifecycle state is otherwise permissive. +- A non-member or wrong-plane actor must not learn whether a workspace is in `grace` or `suspended_read_only`; those requests continue to resolve as 404. +- Suspended/read-only behavior must never revoke access to already-generated artifacts or review/evidence history that the actor is otherwise allowed to read. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature changes runtime behavior and writes workspace-owned commercial state, but it adds no Microsoft Graph calls, no new provider dispatch path, and no new queued workflow family. Lifecycle state changes use the existing workspace settings infrastructure and audit foundation. Existing review-pack `OperationRun` behavior is reused only when lifecycle state allows a start action. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces one new business-state family because current-release operator workflows now need a workspace-wide commercial posture that per-key entitlement decisions cannot express alone. A narrower local-only approach would still scatter lifecycle semantics across onboarding and review-pack surfaces. + +**Constitution alignment (XCUT-001):** All in-scope gated behaviors and preserved read-only surfaces must consume the same lifecycle decision. No local page is allowed to invent its own trial, grace, or suspension semantics. + +**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** Blocked onboarding and blocked review-pack starts must show customer-safe or operator-safe default content first, with diagnostics and support-only detail remaining secondary. Suspended read-only surfaces must preserve one calm next step instead of turning history surfaces into error pages. + +**Constitution alignment (PROV-001):** Commercial lifecycle state is platform-core workspace truth and must not import provider-specific vocabulary or billing-provider semantics. + +**Constitution alignment (TEST-GOV-001):** Proof remains in focused unit plus feature lanes. New fixtures stay limited to workspace, platform operator, workspace member, onboarding draft, tenant count, and existing review-pack/evidence artifacts. + +**Constitution alignment (OPS-UX):** This feature does not create a new run family. Existing review-pack generation keeps the current queued toast, operation link, and terminal notification path when lifecycle state allows it. Blocked lifecycle starts create no run and no run lifecycle feedback. + +**Constitution alignment (OPS-UX-START-001):** Lifecycle gating sits before review-pack run creation and delegates all allowed queued-start UX to the existing shared review-pack path. + +**Constitution alignment (RBAC-UX):** Two authorization planes are involved: platform `/system` for lifecycle mutation and tenant/admin `/admin` for contextual blocked-or-allowed behavior. Wrong-plane or non-member requests remain 404. Members missing capability remain 403. Lifecycle blocking is a product-state response for otherwise-authorized actors and must not masquerade as authorization failure. + +**Constitution alignment (BADGE-001):** If lifecycle badges or state chips are rendered, their labels and visual semantics must come from one shared lifecycle vocabulary rather than page-local color mapping. + +**Constitution alignment (UI-FIL-001):** The slice must extend existing native Filament detail, wizard, widget, resource, and download surfaces. No custom commercial panel or page-local status language is allowed. + +**Constitution alignment (UI-NAMING-001):** Primary labels remain product-facing and specific: `Trial`, `Grace`, `Active paid`, `Suspended / read-only`, `Change commercial state`, `Complete onboarding`, `Generate pack`, `Regenerate`, and `Export executive pack`. Billing-provider or checkout terminology remains out of scope. + +**Constitution alignment (DECIDE-001):** The system workspace detail page is the one primary commercial decision surface. Onboarding and review-pack surfaces remain contextual decision points that only show the commercial truth required for the immediate action. Existing history/evidence surfaces remain tertiary read-only contexts. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The feature must preserve the existing system detail, guided onboarding, grouped review-pack actions, and read-only artifact consumption patterns. It may not create a second admin-plane commercial management surface or redundant inspect actions. + +**Constitution alignment (ACTSURF-001 - action hierarchy):** Lifecycle mutation stays on the system workspace detail page. Onboarding completion remains the primary activation action. Review-pack start actions remain the primary reporting mutations where they already exist. View/download history remains secondary but available during suspension. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** One thin lifecycle overlay is justified because direct reads from the existing entitlement substrate cannot express one workspace-wide read-only posture. Tests must prove business outcomes such as allowed, warned, blocked, and preserved-read behavior rather than badge rendering alone. + +**Constitution alignment (Filament Action Surfaces):** The action-surface contract remains satisfied with the documented system detail exception for one bounded mutation action, the existing onboarding wizard exception, and the existing review-pack action family. No empty action groups or redundant view actions are introduced by this slice. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** The feature extends the existing system detail, onboarding, and review-pack surfaces with bounded state information only. It does not create a new commercial page shell or duplicate summary screen. + +### Functional Requirements + +- **FR-251-001 Central lifecycle state**: The system MUST resolve one commercial lifecycle state per workspace with exactly four values: `trial`, `grace`, `active_paid`, and `suspended_read_only`. +- **FR-251-002 Existing entitlement substrate remains canonical**: The system MUST layer lifecycle state on top of the existing Spec 247 entitlement substrate rather than replacing plan profiles, entitlement keys, or override logic. +- **FR-251-003 Deterministic default**: If no explicit lifecycle state has been stored for a workspace, the system MUST resolve to `active_paid` so existing behavior remains unchanged until an operator intentionally changes state. +- **FR-251-004 Workspace-owned persistence**: The system MUST store lifecycle state and rationale through the existing workspace settings infrastructure instead of introducing a new customer-account, subscription, or billing table. +- **FR-251-005 Platform-managed mutation**: Only authorized platform users MAY change or override lifecycle state in this slice, and the workspace or tenant admin plane MUST NOT become a self-service lifecycle control surface. +- **FR-251-006 Decision shape**: The effective lifecycle decision MUST include the state, source, operator-visible rationale, last changed attribution, and a summary of which in-scope behaviors are currently warned, allowed, or blocked. +- **FR-251-007 State precedence**: Lifecycle state MUST apply after the existing entitlement substrate and MAY only warn or restrict. It MUST NOT expand access beyond what the underlying entitlement decision already allows. +- **FR-251-008 Onboarding activation gate**: Managed-tenant onboarding activation MUST consult the shared lifecycle decision before mutation. `grace` and `suspended_read_only` MUST block activation before any tenant activation state changes occur. +- **FR-251-009 Review-pack start gate**: `Generate pack`, `Regenerate`, and `Export executive pack` MUST consult the shared lifecycle decision before creating or reusing a `ReviewPack` or `OperationRun`. `suspended_read_only` MUST block those actions before any new run or artifact start occurs. +- **FR-251-010 Grace semantics**: `grace` MUST have a distinct behavioral consequence from `active_paid` by freezing new managed-tenant onboarding activation while leaving in-scope review-pack start behavior under the existing entitlement substrate. +- **FR-251-011 Suspended read-only semantics**: `suspended_read_only` MUST block onboarding activation and review-pack start actions while preserving authorized read-only access to existing review history, evidence, and already-generated review-pack consumption. +- **FR-251-012 In-flight behavior boundary**: A lifecycle state change to `suspended_read_only` MUST affect future start attempts only in this slice and MUST NOT retroactively cancel already-created review-pack runs. +- **FR-251-013 Message semantics**: Gated surfaces MUST clearly distinguish lifecycle business-state blocking from entitlement-limit blocking and from authorization failure. +- **FR-251-014 System visibility**: The system workspace detail surface MUST show the current lifecycle state, rationale, affected behavior summary, and last changed attribution to authorized platform users. +- **FR-251-015 Auditability**: Every lifecycle state change and manual override MUST create an auditable record containing old state, new state, actor, and rationale. +- **FR-251-016 No scattered lifecycle conditionals**: Onboarding, review-pack generation, and preserved read-only surfaces MUST use the shared lifecycle decision rather than local page-specific commercial booleans. +- **FR-251-017 Bounded non-goals**: This slice MUST NOT introduce payment providers, invoices, taxes, accounting, checkout, public pricing, website work, customer-account modeling, broad billing automation, or broad entitlement spread beyond the in-scope behaviors above. + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Platform workspace commercial-state controls | existing system workspace detail surface | none on collection | dedicated detail route | none | none | N/A | `Change commercial state` with bounded state selection and rationale; `Suspended / read-only` path requires explicit confirmation | N/A | yes | Existing system-detail exception remains bounded to one platform mutation surface | +| Managed tenant onboarding activation gate | existing onboarding wizard completion step | existing back-navigation and tenant links | N/A - guided workflow | none | none | existing onboarding start state unchanged | `Complete onboarding` remains the primary action and becomes lifecycle-gated | N/A | yes - existing onboarding activation audit path | Existing wizard exception remains valid | +| Review-pack generation entry family | existing tenant dashboard, review register, tenant review detail, and review-pack detail/registry surfaces | current `Generate pack`, `Regenerate`, and `Export executive pack` actions stay primary where already present | existing registry/detail affordances remain unchanged | existing `View` or `Download` shortcuts remain secondary where already present | none | existing `Generate` CTA remains where already present | existing start actions are lifecycle-gated; `View` and `Download` remain outside the blocked-start gate | N/A | no new audit requirement for blocked attempts | Grouped action family stays consistent and does not invent new local start actions | +| Existing read-only review, evidence, and generated-pack consumption surfaces | existing review/evidence/detail/download surfaces | none | existing detail routes | existing `View` or `Download` actions remain available under current RBAC | none | N/A | existing read-only view/download actions remain available during suspension | N/A | no new audit action; read-only continuation only | No new surface is created; the slice only preserves availability semantics | + +### Key Entities *(include if feature involves data)* + +- **Workspace Commercial Lifecycle Setting**: Workspace-owned commercial posture consisting of lifecycle state, rationale, and last change attribution, persisted through the existing workspace settings infrastructure. +- **Effective Commercial Lifecycle Decision**: Derived decision that overlays the existing entitlement substrate and answers whether in-scope behaviors are allowed, warned, or blocked, plus why. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Authorized platform operators can inspect and change a workspace commercial lifecycle state from one system workspace detail surface and see the updated state plus rationale immediately afterward. +- **SC-002**: Authorized workspace operators can determine in under 30 seconds whether onboarding activation or review-pack start is blocked by commercial state rather than by missing permission or underlying entitlement limits. +- **SC-003**: 100% of `suspended_read_only` blocked onboarding or review-pack start attempts stop before activation mutation or new run/artifact creation, while authorized readers still retain access to already-generated history and evidence. +- **SC-004**: Every commercial lifecycle state change produces one auditable old-state to new-state record with actor and rationale, and platform support can inspect that state from one canonical system surface. diff --git a/specs/251-commercial-entitlements-billing-state/tasks.md b/specs/251-commercial-entitlements-billing-state/tasks.md new file mode 100644 index 00000000..e675385f --- /dev/null +++ b/specs/251-commercial-entitlements-billing-state/tasks.md @@ -0,0 +1,190 @@ +--- + +description: "Task list for feature implementation" +--- + +# Tasks: Commercial Entitlements and Billing-State Maturity + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` + +**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in focused `Unit` plus `Feature` lanes only, using the targeted Sail commands already captured in the feature spec, plan, and quickstart artifacts. + +## Test Governance Notes + +- Lane assignment: `fast-feedback` and `confidence` are the narrowest sufficient proof for resolver precedence, system-plane mutation, onboarding gating, review-pack start blocking, and preserved suspended read-only continuation. +- Keep new coverage inside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Entitlements/` plus focused `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/`; do not widen this slice into browser or heavy-governance families. +- Reuse existing workspace, platform-user, workspace-member, onboarding-draft, tenant, review-pack, and evidence fixtures; any new helper or factory state must stay opt-in and cheap by default. +- If implementation needs a bounded exception for blocked-decision transport or preserved read-only scope, record `document-in-feature` or `follow-up-spec` in the final close-out task instead of widening feature scope. + +## Scope Control Notes + +- Keep implementation inside one commercial lifecycle overlay, one system-plane lifecycle mutation surface, managed-tenant onboarding activation gating, review-pack generation/regeneration/export gating, and preserved read-only review/evidence/download semantics while suspended. +- Do not add payment provider, invoicing, checkout, website, customer-account, localization, external support-desk handoff, or broad billing-platform work. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Lock the bounded slice, contract semantics, and validation plan before runtime edits begin. + +- [x] T001 Review the bounded slice, explicit non-goals, scope-control decisions, and review outcomes in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/checklists/requirements.md` +- [x] T002 [P] Review the lifecycle-state model, system/admin split, preserved read-only contract, and 404 versus 403 versus business-state semantics in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/data-model.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml` +- [x] T003 [P] Confirm the focused Sail/Pest proof commands and reviewer scenarios in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the shared lifecycle primitives that every user story depends on. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [x] T004 [P] Register the commercial lifecycle state and rationale setting definitions, validation metadata, and operator-facing labels in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Settings/SettingsRegistry.php` +- [x] T005 [P] Add the bounded four-state catalog, action-decision matrix, and shared overlay resolution logic in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` +- [x] T006 Thread lifecycle setting resolution, default `active_paid` fallback, and lifecycle change attribution through `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsResolver.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsWriter.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` + +**Checkpoint**: Foundation ready. User story work can now proceed independently without inventing local lifecycle state. + +--- + +## Phase 3: User Story 1 - Set Workspace Commercial State Centrally (Priority: P1) 🎯 MVP + +**Goal**: Let an authorized platform operator inspect and change one workspace commercial lifecycle state from the existing system workspace detail surface. + +**Independent Test**: Open `/system/directory/workspaces/{workspace}` as an authorized and unauthorized platform actor, change the lifecycle state with rationale, and verify the page shows current state, affected behavior summary, last-changed attribution, and audit-backed mutation semantics without creating a second control plane. + +### Tests for User Story 1 + +- [x] T007 [P] [US1] Add unit coverage for default `active_paid` fallback, explicit stored states, `default_active_paid` versus `workspace_setting` source resolution, grace versus suspended action outcomes, and last-change attribution in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php` +- [x] T008 [P] [US1] Extend system-plane feature coverage for lifecycle summary and source-label rendering, capability-gated mutation, confirmation plus rationale validation for every explicit transition, and 404 versus 403 semantics in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php` + +### Implementation for User Story 1 + +- [x] T009 [US1] Add the dedicated commercial-lifecycle management capability and apply it to the system workspace detail action surface in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Auth/PlatformCapabilities.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` +- [x] T010 [US1] Project the shared lifecycle state, source label, rationale, affected-behavior summary, and last-changed attribution onto `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php` +- [x] T011 [US1] Add the confirmation-protected `Change commercial state` action with audited old/new state writes and rationale validation for every explicit lifecycle transition in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsWriter.php` + +**Checkpoint**: User Story 1 is independently functional when the system plane exposes one canonical lifecycle decision and one audited mutation path. + +--- + +## Phase 4: User Story 2 - Truthfully Gate Managed-Tenant Activation (Priority: P1) + +**Goal**: Keep onboarding completion visible to otherwise authorized workspace actors while blocking activation with business-state truth when `grace` or `suspended_read_only` freezes expansion. + +**Independent Test**: Seed workspaces in `trial`, `active_paid`, `grace`, and `suspended_read_only`, open the existing onboarding completion step, and verify that activation is either allowed or blocked with the correct lifecycle explanation before any tenant activation mutation occurs. + +### Tests for User Story 2 + +- [x] T012 [P] [US2] Extend onboarding feature coverage for trial/active allow, grace block, suspended block, and 404 versus 403 versus business-state outcomes in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php` + +### Implementation for User Story 2 + +- [x] T013 [US2] Project the shared lifecycle decision onto the onboarding completion step and helper text in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [x] T014 [US2] Enforce lifecycle blocking before any tenant activation mutation or onboarding completion audit path in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [x] T015 [US2] Keep grace and suspended explanations distinct from entitlement-limit and authorization failures by sourcing block messaging from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` inside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` + +**Checkpoint**: User Story 2 is independently functional when onboarding activation exposes one truthful lifecycle decision and never mutates tenant state after a commercial-state block. + +--- + +## Phase 5: User Story 3 - Block New Review-Pack Starts While Preserving Read-Only History (Priority: P2) + +**Goal**: Reuse one lifecycle decision for `Generate pack`, `Regenerate`, and `Export executive pack` while keeping current review, evidence, and already-generated pack consumption available under existing RBAC during suspension. + +**Independent Test**: Switch a workspace with existing review history, evidence, and generated packs to `suspended_read_only`, verify that all in-scope start actions block before any new `ReviewPack` or `OperationRun` write occurs, and confirm that authorized actors can still view or download existing artifacts. + +### Tests for User Story 3 + +- [x] T016 [P] [US3] Extend review-pack feature coverage for allowed `trial`/`active_paid`, warned-but-allowed `grace` starts, blocked `suspended_read_only` starts, no new `ReviewPack` or `OperationRun` writes, no queued or terminal notification on blocked starts, and already queued or running review-pack work remaining unaffected by later suspension in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php` +- [x] T017 [P] [US3] Extend suspended read-only consumption coverage for customer review workspace access, current pack download, and evidence snapshot detail access in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` + +### Implementation for User Story 3 + +- [x] T018 [US3] Enforce lifecycle gating before any new `ReviewPack`, `OperationRun`, or blocked-start notification path and reuse the existing blocked-decision transport instead of adding a second exception path while leaving already-created runs unaffected in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/ReviewPackService.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Exceptions/Entitlements/WorkspaceEntitlementBlockedException.php` +- [x] T019 [P] [US3] Project lifecycle allow/warn/block messaging onto the tenant dashboard and review register start surfaces in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php` +- [x] T020 [P] [US3] Gate `Generate pack`, `Regenerate`, and `Export executive pack` actions while keeping `View` and `Download` affordances unchanged in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ListReviewPacks.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php` +- [x] T021 [US3] Preserve suspended read-only review history, evidence, and generated-pack consumption without widening into a broader suspension sweep in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php` + +**Checkpoint**: User Story 3 is independently functional when all in-scope start actions share one lifecycle gate and suspended workspaces still retain safe read-only access to existing history and evidence. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Run the narrow validation lanes, format touched files, and capture the feature-local close-out without widening scope. + +- [x] T022 Run the targeted unit Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php` +- [x] T023 Run the targeted system-plane and onboarding Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php` +- [x] T024 Run the targeted review-pack, blocked-start no-notification, in-flight-boundary, and preserved-read-only Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` +- [x] T025 Run dirty-only Pint through Sail for touched platform files using the command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` +- [x] T026 Record the final guardrail close-out, lane results, workflow outcome (`keep` unless implementation proves otherwise), and any bounded `document-in-feature` or `follow-up-spec` note for blocked-decision transport or preserved read-only scope in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/checklists/requirements.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: starts immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories. +- **Phase 3 (US1)**, **Phase 4 (US2)**, and **Phase 5 (US3)**: each depends on Phase 2 and is independently testable after the shared lifecycle setting and resolver primitives exist. +- **Phase 6 (Polish)**: depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: first shippable increment once Phase 2 is complete. +- **US2 (P1)**: independently testable after Phase 2 and should follow US1 in the main implementation loop because the system-plane lifecycle vocabulary and audit semantics become canonical there. +- **US3 (P2)**: independently testable after Phase 2 and should merge after US1 because review-pack surfaces must reuse the same lifecycle vocabulary and blocked-decision transport. + +### Within Each User Story + +- Write the listed Pest coverage first and make it fail for the intended gap before implementation. +- Complete the shared service or enforcement seam before wiring multiple UI entry points that depend on it. +- Re-run the narrowest relevant proof command after each story checkpoint before moving to the next story. + +--- + +## Parallel Opportunities + +### Phase 1 + +- T002 and T003 can run in parallel after T001 confirms the bounded slice. + +### Phase 2 + +- T004 and T005 can run in parallel. +- T006 should follow once the lifecycle setting keys and resolver shape exist. + +### User Story 1 + +- T007 and T008 can run in parallel. +- T009 can proceed before T010 and T011, but T010 and T011 should stay coordinated because both touch `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php`. + +### User Story 2 + +- T012 can run in parallel with any remaining US1 validation once Phase 2 is complete. +- T013, T014, and T015 should stay sequential because they all tighten the same onboarding completion boundary. + +### User Story 3 + +- T016 and T017 can run in parallel. +- After T018 establishes the service-level gate, T019 and T020 can run in parallel. +- T021 should follow the shared start-gate work so preserved read-only semantics stay bounded to existing consumption surfaces. + +--- + +## Implementation Strategy + +### Suggested MVP Scope + +- MVP = **Phase 2 + User Story 1 + User Story 2**. This is the smallest slice that creates canonical lifecycle truth, exposes the one platform-side mutation surface, and proves a real business-state consequence (`grace` / `suspended_read_only` onboarding activation gating) without yet widening into review-pack and preserved-history follow-up. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 and validate system-plane lifecycle mutation plus audit semantics. +3. Deliver US2 and validate onboarding business-state gating. +4. Deliver US3 and validate review-pack start blocking plus preserved suspended read-only history/evidence/download access. +5. Finish with Phase 6 validation, formatting, and feature-local close-out recording. \ No newline at end of file -- 2.45.2 From 7613e339c4811aa4193e7292e6cc48010960621e Mon Sep 17 00:00:00 2001 From: ahmido Date: Tue, 28 Apr 2026 19:45:03 +0000 Subject: [PATCH 26/36] feat: implement platform localization v1 (#293) ## Summary - add the localization v1 foundation with request-time locale resolution and workspace or user preference handling - localize the first-wave platform surfaces for auth, shell, dashboards, findings, baseline compare, and review workspace chrome - add Pest coverage for locale resolution, preference flows, fallback behavior, notifications, and governance surface localization ## Scope - active spec: specs/252-platform-localization-v1 - target branch: dev ## Notes - machine-readable artifacts remain invariant and are not localized in this slice - the branch includes the related spec kit artifacts for the feature Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/293 --- .../app/Filament/Pages/Auth/Login.php | 5 + .../Pages/Reviews/CustomerReviewWorkspace.php | 80 +++-- .../Pages/Settings/WorkspaceSettings.php | 75 +++- .../app/Filament/Pages/TenantDashboard.php | 35 +- .../Filament/Resources/FindingResource.php | 22 +- .../FindingResource/Pages/ListFindings.php | 10 +- .../FindingResource/Pages/ViewFinding.php | 4 +- .../Resources/TenantReviewResource.php | 138 ++++---- .../app/Filament/System/Pages/Dashboard.php | 17 +- .../Controllers/LocalizationController.php | 80 +++++ .../Http/Middleware/ApplyResolvedLocale.php | 29 ++ apps/platform/app/Models/User.php | 1 + .../Providers/Filament/AdminPanelProvider.php | 21 +- .../Filament/SystemPanelProvider.php | 9 + .../Filament/TenantPanelProvider.php | 13 +- .../Services/Localization/LocaleResolver.php | 215 ++++++++++++ .../app/Support/Settings/SettingsRegistry.php | 22 +- apps/platform/bootstrap/app.php | 6 + ...00_add_preferred_locale_to_users_table.php | 25 ++ apps/platform/lang/de/baseline-compare.php | 88 +++++ apps/platform/lang/de/findings.php | 31 ++ apps/platform/lang/de/localization.php | 230 +++++++++++++ apps/platform/lang/en/localization.php | 230 +++++++++++++ .../governance-artifact-truth.blade.php | 16 +- .../entries/tenant-review-section.blade.php | 6 +- .../entries/tenant-review-summary.blade.php | 24 +- .../views/filament/pages/auth/login.blade.php | 6 +- .../customer-review-workspace.blade.php | 8 +- .../filament/partials/context-bar.blade.php | 41 +-- .../partials/locale-switcher.blade.php | 110 ++++++ apps/platform/routes/web.php | 16 + .../CoreGovernanceSurfaceLocalizationTest.php | 16 + .../AuthAndSystemSurfaceLocalizationTest.php | 43 +++ .../Localization/LocalePreferenceFlowTest.php | 87 +++++ .../LocalizedNotificationFormattingTest.php | 47 +++ .../MachineFormatInvarianceTest.php | 31 ++ .../TranslationFallbackGuardTest.php | 23 ++ .../WorkspaceDefaultLocaleTest.php | 50 +++ apps/platform/tests/Pest.php | 19 ++ .../Unit/Localization/LocaleResolverTest.php | 83 +++++ docs/product/implementation-ledger.md | 12 +- docs/product/spec-candidates.md | 90 ++++- .../checklists/requirements.md | 60 ++++ ...ranslation-governance.logical.openapi.yaml | 177 ++++++++++ .../data-model.md | 65 ++++ specs/252-platform-localization-v1/plan.md | 287 ++++++++++++++++ .../quickstart.md | 39 +++ .../252-platform-localization-v1/research.md | 51 +++ specs/252-platform-localization-v1/spec.md | 319 ++++++++++++++++++ specs/252-platform-localization-v1/tasks.md | 187 ++++++++++ 50 files changed, 3094 insertions(+), 205 deletions(-) create mode 100644 apps/platform/app/Http/Controllers/LocalizationController.php create mode 100644 apps/platform/app/Http/Middleware/ApplyResolvedLocale.php create mode 100644 apps/platform/app/Services/Localization/LocaleResolver.php create mode 100644 apps/platform/database/migrations/2026_04_28_000000_add_preferred_locale_to_users_table.php create mode 100644 apps/platform/lang/de/baseline-compare.php create mode 100644 apps/platform/lang/de/findings.php create mode 100644 apps/platform/lang/de/localization.php create mode 100644 apps/platform/lang/en/localization.php create mode 100644 apps/platform/resources/views/filament/partials/locale-switcher.blade.php create mode 100644 apps/platform/tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php create mode 100644 apps/platform/tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php create mode 100644 apps/platform/tests/Feature/Localization/LocalePreferenceFlowTest.php create mode 100644 apps/platform/tests/Feature/Localization/LocalizedNotificationFormattingTest.php create mode 100644 apps/platform/tests/Feature/Localization/MachineFormatInvarianceTest.php create mode 100644 apps/platform/tests/Feature/Localization/TranslationFallbackGuardTest.php create mode 100644 apps/platform/tests/Feature/Localization/WorkspaceDefaultLocaleTest.php create mode 100644 apps/platform/tests/Unit/Localization/LocaleResolverTest.php create mode 100644 specs/252-platform-localization-v1/checklists/requirements.md create mode 100644 specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml create mode 100644 specs/252-platform-localization-v1/data-model.md create mode 100644 specs/252-platform-localization-v1/plan.md create mode 100644 specs/252-platform-localization-v1/quickstart.md create mode 100644 specs/252-platform-localization-v1/research.md create mode 100644 specs/252-platform-localization-v1/spec.md create mode 100644 specs/252-platform-localization-v1/tasks.md diff --git a/apps/platform/app/Filament/Pages/Auth/Login.php b/apps/platform/app/Filament/Pages/Auth/Login.php index e8b07ab6..d282cd39 100644 --- a/apps/platform/app/Filament/Pages/Auth/Login.php +++ b/apps/platform/app/Filament/Pages/Auth/Login.php @@ -9,4 +9,9 @@ class Login extends BaseLogin { protected string $view = 'filament.pages.auth.login'; + + public function getTitle(): string + { + return __('localization.auth.sign_in_microsoft'); + } } diff --git a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php index 190a12e7..8b41da58 100644 --- a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php +++ b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php @@ -57,6 +57,21 @@ class CustomerReviewWorkspace extends Page implements HasTable protected string $view = 'filament.pages.reviews.customer-review-workspace'; + public static function getNavigationGroup(): string + { + return __('localization.review.reporting'); + } + + public static function getNavigationLabel(): string + { + return __('localization.review.customer_reviews'); + } + + public function getTitle(): string + { + return __('localization.review.customer_review_workspace'); + } + public static function tenantPrefilterUrl(Tenant $tenant): string { $tenantIdentifier = filled($tenant->external_id) @@ -84,7 +99,7 @@ protected function getHeaderActions(): array { return [ Action::make('clear_filters') - ->label('Clear filters') + ->label(__('localization.review.clear_filters')) ->icon('heroicon-o-x-mark') ->color('gray') ->visible(fn (): bool => $this->hasActiveFilters()) @@ -105,9 +120,9 @@ public function table(Table $table): Table ->persistSortInSession() ->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record)) ->columns([ - TextColumn::make('name')->label('Tenant')->searchable()->sortable(), + TextColumn::make('name')->label(__('localization.review.tenant'))->searchable()->sortable(), TextColumn::make('latest_review') - ->label('Latest review') + ->label(__('localization.review.latest_review')) ->badge() ->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record)) ->color(fn (Tenant $record): string => $this->latestReviewStateColor($record)) @@ -116,25 +131,25 @@ public function table(Table $table): Table ->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record)) ->wrap(), TextColumn::make('finding_summary') - ->label('Key findings') + ->label(__('localization.review.key_findings')) ->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record)) ->wrap(), TextColumn::make('accepted_risk_summary') - ->label('Accepted risks') + ->label(__('localization.review.accepted_risks')) ->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record)) ->wrap(), TextColumn::make('published_at') - ->label('Published') + ->label(__('localization.review.published')) ->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString()) ->dateTime() ->placeholder('—'), TextColumn::make('review_pack_state') - ->label('Review pack') + ->label(__('localization.review.review_pack')) ->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)), ]) ->filters([ SelectFilter::make('tenant_id') - ->label('Tenant') + ->label(__('localization.review.tenant')) ->options(fn (): array => $this->tenantFilterOptions()) ->default(fn (): ?string => $this->defaultTenantFilter()) ->query(function (Builder $query, array $data): Builder { @@ -148,25 +163,25 @@ public function table(Table $table): Table ]) ->actions([ Action::make('open_latest_review') - ->label('Open latest review') + ->label(__('localization.review.open_latest_review')) ->icon('heroicon-o-arrow-top-right-on-square') ->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record)) ->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview), Action::make('download_review_pack') - ->label('Download review pack') + ->label(__('localization.review.download_review_pack')) ->icon('heroicon-o-arrow-down-tray') ->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record)) ->openUrlInNewTab() ->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))), ]) ->bulkActions([]) - ->emptyStateHeading('No entitled tenants match this view') + ->emptyStateHeading(__('localization.review.no_entitled_tenants')) ->emptyStateDescription(fn (): string => $this->hasActiveFilters() - ? 'Clear the current filters to return to the full customer review workspace for your entitled tenants.' - : 'Adjust filters to return to the full customer review workspace for your entitled tenants.') + ? __('localization.review.clear_filters_description') + : __('localization.review.adjust_filters_description')) ->emptyStateActions([ Action::make('clear_filters_empty') - ->label('Clear filters') + ->label(__('localization.review.clear_filters')) ->icon('heroicon-o-x-mark') ->color('gray') ->visible(fn (): bool => $this->hasActiveFilters()) @@ -387,7 +402,7 @@ private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome private function latestReviewStateLabel(Tenant $tenant): string { - return $this->reviewOutcome($tenant)?->primaryLabel ?? 'No published review'; + return $this->reviewOutcome($tenant)?->primaryLabel ?? __('localization.review.no_published_review'); } private function latestReviewStateColor(Tenant $tenant): string @@ -410,7 +425,7 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string $review = $this->latestPublishedReview($tenant); if (! $review instanceof TenantReview) { - return 'No published review available yet'; + return __('localization.review.no_published_review_available'); } $primaryReason = $this->reviewOutcome($tenant)?->primaryReason; @@ -427,7 +442,7 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string return $primaryReason; } - return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.'); + return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.'); } private function findingSummary(Tenant $tenant): string @@ -435,7 +450,7 @@ private function findingSummary(Tenant $tenant): string $review = $this->latestPublishedReview($tenant); if (! $review instanceof TenantReview) { - return 'No published review available yet'; + return __('localization.review.no_published_review_available'); } $summary = is_array($review->summary) ? $review->summary : []; @@ -444,14 +459,17 @@ private function findingSummary(Tenant $tenant): string $terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes); if ($findingCount === 0) { - return 'No findings recorded in the published review.'; + return __('localization.review.no_findings_recorded'); } if ($terminalOutcomes === null) { - return sprintf('%d findings summarized in the published review.', $findingCount); + return __('localization.review.findings_count_summary', ['count' => $findingCount]); } - return sprintf('%d findings. Terminal outcomes: %s.', $findingCount, $terminalOutcomes); + return __('localization.review.findings_count_with_outcomes', [ + 'count' => $findingCount, + 'outcomes' => $terminalOutcomes, + ]); } private function acceptedRiskSummary(Tenant $tenant): string @@ -459,7 +477,7 @@ private function acceptedRiskSummary(Tenant $tenant): string $review = $this->latestPublishedReview($tenant); if (! $review instanceof TenantReview) { - return 'No published review available yet'; + return __('localization.review.no_published_review_available'); } $summary = is_array($review->summary) ? $review->summary : []; @@ -469,10 +487,10 @@ private function acceptedRiskSummary(Tenant $tenant): string $warningCount = (int) ($riskAcceptance['warning_count'] ?? 0); return match (true) { - $statusMarkedCount === 0 => 'No accepted risks recorded.', - $warningCount > 0 => sprintf('%d accepted risks need governance follow-up (%d total).', $warningCount, $statusMarkedCount), - $validGovernedCount > 0 => sprintf('%d accepted risks are governed.', $validGovernedCount), - default => sprintf('%d accepted risks are on record.', $statusMarkedCount), + $statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'), + $warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]), + $validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]), + default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]), }; } @@ -481,17 +499,17 @@ private function reviewPackAvailability(Tenant $tenant): string $pack = $this->latestReviewPack($tenant); if (! $pack instanceof ReviewPack) { - return 'Unavailable'; + return __('localization.review.unavailable'); } if ($pack->status !== ReviewPackStatus::Ready->value) { - return 'Unavailable'; + return __('localization.review.unavailable'); } if ($pack->expires_at !== null && $pack->expires_at->isPast()) { - return 'Unavailable'; + return __('localization.review.unavailable'); } - return 'Available'; + return __('localization.review.available'); } -} \ No newline at end of file +} diff --git a/apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php b/apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php index 062d2616..203e3e51 100644 --- a/apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php +++ b/apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php @@ -12,6 +12,7 @@ use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Entitlements\WorkspacePlanProfileCatalog; +use App\Services\Localization\LocaleResolver; use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsWriter; use App\Support\Auth\Capabilities; @@ -58,6 +59,7 @@ class WorkspaceSettings extends Page */ private const SETTING_FIELDS = [ 'ai_policy_mode' => ['domain' => 'ai', 'key' => 'policy_mode', 'type' => 'string'], + 'localization_default_locale' => ['domain' => 'localization', 'key' => 'default_locale', 'type' => 'string'], 'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'], 'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'], 'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'], @@ -153,17 +155,22 @@ protected function getHeaderActions(): array { return [ Action::make('save') - ->label('Save') + ->label(__('localization.workspace.save')) ->action(function (): void { $this->save(); }) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->tooltip(fn (): ?string => $this->currentUserCanManage() ? null - : 'You do not have permission to manage workspace settings.'), + : __('localization.workspace.no_manage_permission')), ]; } + public function getTitle(): string + { + return __('localization.workspace.title'); + } + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) @@ -208,6 +215,18 @@ public function content(Schema $schema): Schema return $schema ->statePath('data') ->schema([ + Section::make(__('localization.workspace.section')) + ->description($this->sectionDescription('localization', __('localization.workspace.section_description'))) + ->schema([ + Select::make('localization_default_locale') + ->label(__('localization.workspace.default_locale_label')) + ->options(LocaleResolver::localeOptions()) + ->placeholder(__('localization.workspace.default_locale_placeholder')) + ->native(false) + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->helperText(fn (): string => $this->localeDefaultHelperText()) + ->hintAction($this->makeResetAction('localization_default_locale')), + ]), Section::make('Workspace entitlements') ->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.')) ->columns(2) @@ -507,7 +526,7 @@ public function save(): void $this->loadFormState(); Notification::make() - ->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save') + ->title($changedSettingsCount > 0 ? __('localization.notifications.workspace_settings_saved') : __('localization.notifications.workspace_settings_unchanged')) ->success() ->send(); } @@ -526,7 +545,7 @@ public function resetSetting(string $field): void if ($this->workspaceOverrideForField($field) === null) { Notification::make() - ->title('Setting already uses default') + ->title(__('localization.notifications.setting_already_default')) ->success() ->send(); @@ -543,7 +562,7 @@ public function resetSetting(string $field): void $this->loadFormState(); Notification::make() - ->title('Workspace setting reset to default') + ->title(__('localization.notifications.workspace_setting_reset')) ->success() ->send(); } @@ -692,18 +711,17 @@ private function sectionDescription(string $domain, string $baseDescription): st /** @var Carbon $updatedAt */ $updatedAt = $meta['updated_at']; - return sprintf( - '%s — Last modified by %s, %s.', - $baseDescription, - $meta['user_name'], - $updatedAt->diffForHumans(), - ); + return __('localization.workspace.last_modified_by', [ + 'description' => $baseDescription, + 'user' => $meta['user_name'], + 'time' => $updatedAt->diffForHumans(), + ]); } private function makeResetAction(string $field): Action { return Action::make('reset_'.$field) - ->label('Reset') + ->label(__('localization.workspace.reset')) ->color('danger') ->requiresConfirmation() ->action(function () use ($field): void { @@ -718,15 +736,15 @@ private function makeResetAction(string $field): Action ->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field)) ->tooltip(function () use ($field): ?string { if (! $this->currentUserCanManage()) { - return 'You do not have permission to manage workspace settings.'; + return __('localization.workspace.no_manage_permission'); } if (! $this->canResetField($field)) { if ($this->isEntitlementOverrideValueField($field)) { - return 'No workspace override to reset.'; + return __('localization.workspace.no_workspace_override'); } - return 'No workspace override to reset.'; + return __('localization.workspace.no_workspace_override'); } return null; @@ -948,6 +966,29 @@ private function helperTextFor(string $field): string return sprintf('Effective value: %s.', $effectiveValue); } + private function localeDefaultHelperText(): string + { + $resolved = $this->resolvedSettings['localization_default_locale'] ?? null; + + if (! is_array($resolved)) { + return ''; + } + + $effectiveLocale = LocaleResolver::normalize($resolved['value'] ?? null) ?? 'en'; + $localeLabel = LocaleResolver::localeOptions()[$effectiveLocale] ?? strtoupper($effectiveLocale); + + if (! $this->hasWorkspaceOverride('localization_default_locale')) { + return __('localization.workspace.default_locale_helper_unset', [ + 'locale' => $localeLabel, + 'source' => $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')), + ]); + } + + return __('localization.workspace.default_locale_helper_set', [ + 'locale' => $localeLabel, + ]); + } + private function slaFieldHelperText(string $severity): string { $resolved = $this->resolvedSettings['findings_sla_days'] ?? null; @@ -1353,9 +1394,9 @@ private function formatValueForDisplay(string $field, mixed $value): string private function sourceLabel(string $source): string { return match ($source) { - 'workspace_override' => 'workspace override', + 'workspace_override' => __('localization.source.workspace_override'), 'tenant_override' => 'tenant override', - default => 'system default', + default => __('localization.source.system_default'), }; } diff --git a/apps/platform/app/Filament/Pages/TenantDashboard.php b/apps/platform/app/Filament/Pages/TenantDashboard.php index 7d29d148..8af8dbe7 100644 --- a/apps/platform/app/Filament/Pages/TenantDashboard.php +++ b/apps/platform/app/Filament/Pages/TenantDashboard.php @@ -42,6 +42,11 @@ class TenantDashboard extends Dashboard */ public array $supportDiagnosticsAuditKeys = []; + public function getTitle(): string + { + return __('localization.dashboard.tenant_title'); + } + /** * @param array $parameters */ @@ -90,38 +95,38 @@ public function authorizeTenantSupportRequest(): void private function requestSupportAction(): Action { $action = Action::make('requestSupport') - ->label('Request support') + ->label(__('localization.dashboard.request_support')) ->icon('heroicon-o-paper-airplane') ->color('gray') ->slideOver() ->stickyModalHeader() - ->modalHeading('Request support') - ->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from existing records.') - ->modalSubmitActionLabel('Submit request') + ->modalHeading(__('localization.dashboard.support_request_heading')) + ->modalDescription(__('localization.dashboard.support_request_description')) + ->modalSubmitActionLabel(__('localization.dashboard.submit_request')) ->form([ Placeholder::make('included_context') - ->label('Included context') + ->label(__('localization.dashboard.included_context')) ->content(fn (): string => $this->tenantSupportRequestAttachmentSummary()) ->columnSpanFull(), Select::make('severity') - ->label('Severity') + ->label(__('localization.dashboard.severity')) ->options(SupportRequest::severityOptions()) ->default(SupportRequest::SEVERITY_NORMAL) ->required() ->native(false), TextInput::make('summary') - ->label('Summary') + ->label(__('localization.dashboard.summary')) ->required() ->columnSpanFull(), Textarea::make('reproduction_notes') - ->label('Reproduction notes') + ->label(__('localization.dashboard.reproduction_notes')) ->rows(4) ->columnSpanFull(), TextInput::make('contact_name') - ->label('Contact name') + ->label(__('localization.dashboard.contact_name')) ->default(fn (): ?string => $this->resolveDashboardActor()->name), TextInput::make('contact_email') - ->label('Contact email') + ->label(__('localization.dashboard.contact_email')) ->email() ->default(fn (): ?string => $this->resolveDashboardActor()->email), ]) @@ -132,7 +137,7 @@ private function requestSupportAction(): Action $supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data); Notification::make() - ->title('Support request submitted') + ->title(__('localization.dashboard.support_request_submitted')) ->body('Reference '.$supportRequest->internal_reference) ->success() ->send(); @@ -146,16 +151,16 @@ private function requestSupportAction(): Action private function openSupportDiagnosticsAction(): Action { $action = Action::make('openSupportDiagnostics') - ->label('Open support diagnostics') + ->label(__('localization.dashboard.open_support_diagnostics')) ->icon('heroicon-o-lifebuoy') ->color('gray') ->modal() ->slideOver() ->stickyModalHeader() - ->modalHeading('Support diagnostics') - ->modalDescription('Redacted tenant context from existing records.') + ->modalHeading(__('localization.dashboard.support_diagnostics')) + ->modalDescription(__('localization.dashboard.support_diagnostics_description')) ->modalSubmitAction(false) - ->modalCancelAction(fn (Action $action): Action => $action->label('Close')) + ->modalCancelAction(fn (Action $action): Action => $action->label(__('localization.dashboard.close'))) ->mountUsing(function (): void { $this->auditTenantSupportDiagnosticsOpen(); }) diff --git a/apps/platform/app/Filament/Resources/FindingResource.php b/apps/platform/app/Filament/Resources/FindingResource.php index 776a1a62..d7e1f83e 100644 --- a/apps/platform/app/Filament/Resources/FindingResource.php +++ b/apps/platform/app/Filament/Resources/FindingResource.php @@ -75,8 +75,6 @@ class FindingResource extends Resource protected static string|UnitEnum|null $navigationGroup = 'Governance'; - protected static ?string $navigationLabel = 'Findings'; - public static function shouldRegisterNavigation(): bool { if (Filament::getCurrentPanel()?->getId() === 'admin') { @@ -86,6 +84,26 @@ public static function shouldRegisterNavigation(): bool return parent::shouldRegisterNavigation(); } + public static function getNavigationLabel(): string + { + return __('localization.navigation.findings'); + } + + public static function getNavigationGroup(): string + { + return __('localization.navigation.governance'); + } + + public static function getModelLabel(): string + { + return __('localization.navigation.findings'); + } + + public static function getPluralModelLabel(): string + { + return __('localization.navigation.findings'); + } + public static function canViewAny(): bool { $tenant = static::resolveTenantContextForCurrentPanel(); diff --git a/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php b/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php index d1b19989..b46f0a39 100644 --- a/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php +++ b/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php @@ -77,15 +77,15 @@ public function getTabs(): array $stats = FindingResource::findingStatsForCurrentTenant(); return [ - 'all' => Tab::make('All') + 'all' => Tab::make(__('localization.findings.all')) ->icon('heroicon-m-list-bullet'), - 'needs_action' => Tab::make('Needs action') + 'needs_action' => Tab::make(__('localization.findings.needs_action')) ->icon('heroicon-m-exclamation-triangle') ->modifyQueryUsing(fn (Builder $query): Builder => $query ->whereIn('status', Finding::openStatusesForQuery())) ->badge($stats['open'] > 0 ? $stats['open'] : null) ->badgeColor('warning'), - 'overdue' => Tab::make('Overdue') + 'overdue' => Tab::make(__('localization.findings.overdue')) ->icon('heroicon-m-clock') ->modifyQueryUsing(fn (Builder $query): Builder => $query ->whereIn('status', Finding::openStatusesForQuery()) @@ -93,11 +93,11 @@ public function getTabs(): array ->where('due_at', '<', now())) ->badge($stats['overdue'] > 0 ? $stats['overdue'] : null) ->badgeColor('danger'), - 'risk_accepted' => Tab::make('Risk accepted') + 'risk_accepted' => Tab::make(__('localization.findings.risk_accepted')) ->icon('heroicon-m-shield-check') ->modifyQueryUsing(fn (Builder $query): Builder => $query ->where('status', Finding::STATUS_RISK_ACCEPTED)), - 'resolved' => Tab::make('Resolved') + 'resolved' => Tab::make(__('localization.findings.resolved')) ->icon('heroicon-m-archive-box') ->modifyQueryUsing(fn (Builder $query): Builder => $query ->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])), diff --git a/apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php b/apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php index b89e3f30..ab990e4c 100644 --- a/apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php +++ b/apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php @@ -44,7 +44,7 @@ protected function getHeaderActions(): array ->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false)) ->color('gray'), Actions\Action::make('open_approval_queue') - ->label('Open approval queue') + ->label(__('localization.findings.open_approval_queue')) ->icon('heroicon-o-arrow-top-right-on-square') ->color('gray') ->visible(function (): bool { @@ -61,7 +61,7 @@ protected function getHeaderActions(): array : null; }), Actions\ActionGroup::make(FindingResource::workflowActions()) - ->label('Actions') + ->label(__('localization.findings.actions')) ->icon('heroicon-o-ellipsis-vertical') ->color('gray'), ]); diff --git a/apps/platform/app/Filament/Resources/TenantReviewResource.php b/apps/platform/app/Filament/Resources/TenantReviewResource.php index cc66e41b..da7a53f1 100644 --- a/apps/platform/app/Filament/Resources/TenantReviewResource.php +++ b/apps/platform/app/Filament/Resources/TenantReviewResource.php @@ -85,6 +85,26 @@ public static function shouldRegisterNavigation(): bool return Filament::getCurrentPanel()?->getId() === 'tenant'; } + public static function getNavigationGroup(): string + { + return __('localization.review.reporting'); + } + + public static function getNavigationLabel(): string + { + return __('localization.review.reviews'); + } + + public static function getModelLabel(): string + { + return __('localization.review.review'); + } + + public static function getPluralModelLabel(): string + { + return __('localization.review.reviews'); + } + public static function canViewAny(): bool { $tenant = static::resolveTenantContextForCurrentPanel(); @@ -153,7 +173,7 @@ public static function form(Schema $schema): Schema public static function infolist(Schema $schema): Schema { return $schema->schema([ - Section::make('Outcome summary') + Section::make(__('localization.review.outcome_summary')) ->schema([ ViewEntry::make('artifact_truth') ->hiddenLabel() @@ -162,7 +182,7 @@ public static function infolist(Schema $schema): Schema ->columnSpanFull(), ]) ->columnSpanFull(), - Section::make('Review') + Section::make(__('localization.review.review')) ->schema([ TextEntry::make('status') ->badge() @@ -171,23 +191,23 @@ public static function infolist(Schema $schema): Schema ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)), TextEntry::make('completeness_state') - ->label('Completeness') + ->label(__('localization.review.completeness')) ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)), - TextEntry::make('tenant.name')->label('Tenant'), + TextEntry::make('tenant.name')->label(__('localization.review.tenant')), TextEntry::make('generated_at')->dateTime()->placeholder('—'), TextEntry::make('published_at')->dateTime()->placeholder('—'), TextEntry::make('evidenceSnapshot.id') - ->label('Evidence snapshot') + ->label(__('localization.review.evidence_snapshot')) ->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—') ->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot ? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant) : null), TextEntry::make('currentExportReviewPack.id') - ->label('Current export') + ->label(__('localization.review.current_export')) ->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—') ->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack ? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant) @@ -201,7 +221,7 @@ public static function infolist(Schema $schema): Schema ]) ->columns(2) ->columnSpanFull(), - Section::make('Executive posture') + Section::make(__('localization.review.executive_posture')) ->schema([ ViewEntry::make('review_summary') ->hiddenLabel() @@ -210,21 +230,21 @@ public static function infolist(Schema $schema): Schema ->columnSpanFull(), ]) ->columnSpanFull(), - Section::make('Sections') + Section::make(__('localization.review.sections')) ->schema([ RepeatableEntry::make('sections') ->hiddenLabel() ->schema([ TextEntry::make('title'), TextEntry::make('completeness_state') - ->label('Completeness') + ->label(__('localization.review.completeness')) ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)), TextEntry::make('measured_at')->dateTime()->placeholder('—'), - Section::make('Details') + Section::make(__('localization.review.details')) ->schema([ ViewEntry::make('section_payload') ->hiddenLabel() @@ -246,7 +266,7 @@ public static function table(Table $table): Table { $exportExecutivePackAction = UiEnforcement::forTableAction( Actions\Action::make('export_executive_pack') - ->label('Export executive pack') + ->label(__('localization.review.export_executive_pack')) ->icon('heroicon-o-arrow-down-tray') ->visible(fn (TenantReview $record): bool => in_array($record->status, [ TenantReviewStatus::Ready->value, @@ -278,7 +298,7 @@ public static function table(Table $table): Table ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)) ->sortable(), Tables\Columns\TextColumn::make('outcome') - ->label('Outcome') + ->label(__('localization.review.outcome')) ->badge() ->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryLabel) ->color(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryBadge->color) @@ -289,10 +309,10 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(), Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(), Tables\Columns\IconColumn::make('summary.has_ready_export') - ->label('Export') + ->label(__('localization.review.export')) ->boolean(), Tables\Columns\TextColumn::make('next_step') - ->label('Next step') + ->label(__('localization.review.next_step')) ->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->nextActionText) ->wrap(), Tables\Columns\TextColumn::make('fingerprint') @@ -306,18 +326,18 @@ public static function table(Table $table): Table ->all()), Tables\Filters\SelectFilter::make('completeness_state') ->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())), - \App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'), + \App\Support\Filament\FilterPresets::dateRange('review_date', __('localization.review.review_date'), 'generated_at'), ]) ->actions([ $exportExecutivePackAction, ]) ->bulkActions([]) - ->emptyStateHeading('No tenant reviews yet') - ->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.') + ->emptyStateHeading(__('localization.review.no_tenant_reviews_yet')) + ->emptyStateDescription(__('localization.review.create_first_review_description')) ->emptyStateActions([ static::makeCreateReviewAction( name: 'create_first_review', - label: 'Create first review', + label: __('localization.review.create_first_review'), icon: 'heroicon-o-plus', ), ]); @@ -336,19 +356,23 @@ public static function makeCreateReviewAction( string $label = 'Create review', string $icon = 'heroicon-o-plus', ): Actions\Action { + $label = $label === 'Create review' + ? __('localization.review.create_review') + : $label; + return UiEnforcement::forAction( Actions\Action::make($name) ->label($label) ->icon($icon) ->form([ - Section::make('Evidence basis') + Section::make(__('localization.review.evidence_basis')) ->schema([ Select::make('evidence_snapshot_id') - ->label('Evidence snapshot') + ->label(__('localization.review.evidence_snapshot')) ->required() ->options(fn (): array => static::evidenceSnapshotOptions()) ->searchable() - ->helperText('Choose the anchored evidence snapshot for this review.'), + ->helperText(__('localization.review.evidence_basis_helper')), ]), ]) ->action(fn (array $data): mixed => static::executeCreateReview($data)), @@ -366,7 +390,7 @@ public static function executeCreateReview(array $data): void $user = auth()->user(); if (! $tenant instanceof Tenant || ! $user instanceof User) { - Notification::make()->danger()->title('Unable to create review — missing context.')->send(); + Notification::make()->danger()->title(__('localization.review.unable_create_missing_context'))->send(); return; } @@ -388,7 +412,7 @@ public static function executeCreateReview(array $data): void : null; if (! $snapshot instanceof EvidenceSnapshot) { - Notification::make()->danger()->title('Select a valid evidence snapshot.')->send(); + Notification::make()->danger()->title(__('localization.review.select_valid_evidence_snapshot'))->send(); return; } @@ -396,7 +420,7 @@ public static function executeCreateReview(array $data): void try { $review = app(TenantReviewService::class)->create($tenant, $snapshot, $user); } catch (\Throwable $throwable) { - Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send(); + Notification::make()->danger()->title(__('localization.review.unable_create_review'))->body($throwable->getMessage())->send(); return; } @@ -406,11 +430,11 @@ public static function executeCreateReview(array $data): void if (! $review->wasRecentlyCreated) { Notification::make() ->success() - ->title('Review already available') - ->body('A matching mutable review already exists for this evidence basis.') + ->title(__('localization.review.review_already_available')) + ->body(__('localization.review.review_already_available_body')) ->actions([ Actions\Action::make('view_review') - ->label('View review') + ->label(__('localization.review.view_review')) ->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)), ]) ->send(); @@ -419,12 +443,12 @@ public static function executeCreateReview(array $data): void } $toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value) - ->body('The review is being composed in the background.'); + ->body(__('localization.review.review_composing_background')); if ($review->operation_run_id) { $toast->actions([ Actions\Action::make('view_run') - ->label('Open operation') + ->label(__('localization.review.open_operation')) ->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)), ]); } @@ -496,7 +520,7 @@ public static function executeExport(TenantReview $review): void $user = auth()->user(); if (! $user instanceof User || ! $review->tenant instanceof Tenant) { - Notification::make()->danger()->title('Unable to export review — missing context.')->send(); + Notification::make()->danger()->title(__('localization.review.unable_export_missing_context'))->send(); return; } @@ -513,7 +537,7 @@ public static function executeExport(TenantReview $review): void if ($service->checkActiveRunForReview($review)) { OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value) - ->body('An executive pack export is already queued or running for this review.') + ->body(__('localization.review.export_already_queued_body')) ->send(); return; @@ -525,11 +549,11 @@ public static function executeExport(TenantReview $review): void 'include_operations' => true, ]); } catch (WorkspaceEntitlementBlockedException $exception) { - Notification::make()->warning()->title('Executive pack export unavailable')->body($exception->getMessage())->send(); + Notification::make()->warning()->title(__('localization.review.executive_pack_export_unavailable'))->body($exception->getMessage())->send(); return; } catch (\Throwable $throwable) { - Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send(); + Notification::make()->danger()->title(__('localization.review.unable_export_executive_pack'))->body($throwable->getMessage())->send(); return; } @@ -540,11 +564,11 @@ public static function executeExport(TenantReview $review): void if (! $pack->wasRecentlyCreated) { Notification::make() ->success() - ->title('Executive pack already available') - ->body('A matching executive pack already exists for this review.') + ->title(__('localization.review.executive_pack_already_available')) + ->body(__('localization.review.executive_pack_already_available_body')) ->actions([ Actions\Action::make('view_pack') - ->label('View pack') + ->label(__('localization.review.view_pack')) ->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)), ]) ->send(); @@ -553,7 +577,7 @@ public static function executeExport(TenantReview $review): void } OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value) - ->body('The executive pack is being generated in the background.') + ->body(__('localization.review.executive_pack_generating_background')) ->send(); } @@ -593,7 +617,7 @@ private static function evidenceSnapshotOptions(): array '#%d · %s · %s', (int) $snapshot->getKey(), BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label, - $snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending' + $snapshot->generated_at?->format('Y-m-d H:i') ?? __('localization.review.pending') ), ]) ->all(); @@ -617,7 +641,7 @@ private static function summaryPresentation(TenantReview $record): array $findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : []; if ($findingOutcomeSummary !== null) { - $highlights[] = 'Terminal outcomes: '.$findingOutcomeSummary.'.'; + $highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.'; } return [ @@ -629,12 +653,12 @@ private static function summaryPresentation(TenantReview $record): array 'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], 'context_links' => static::summaryContextLinks($record), 'metrics' => [ - ['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)], - ['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)], - ['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)], - ['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)], - ['label' => 'Pending verification', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)], - ['label' => 'Verified cleared', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)], + ['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)], + ['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)], + ['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)], + ['label' => __('localization.review.sections'), 'value' => (string) ($summary['section_count'] ?? 0)], + ['label' => __('localization.review.pending_verification'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)], + ['label' => __('localization.review.verified_cleared'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)], ], ]; } @@ -648,37 +672,37 @@ private static function summaryContextLinks(TenantReview $record): array if (is_numeric($record->operation_run_id)) { $links[] = [ - 'title' => 'Operation', - 'label' => 'Open operation', + 'title' => __('localization.review.operation'), + 'label' => __('localization.review.open_operation'), 'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id), - 'description' => 'Inspect the latest review composition or refresh run.', + 'description' => __('localization.review.operation_description'), ]; } if ($record->currentExportReviewPack && $record->tenant) { $links[] = [ - 'title' => 'Executive pack', - 'label' => 'View executive pack', + 'title' => __('localization.review.executive_pack'), + 'label' => __('localization.review.view_executive_pack'), 'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant), - 'description' => 'Open the current export that belongs to this review.', + 'description' => __('localization.review.executive_pack_description'), ]; } if ($record->tenant) { $links[] = [ - 'title' => 'Customer workspace', - 'label' => 'Open customer workspace', + 'title' => __('localization.review.customer_workspace'), + 'label' => __('localization.review.open_customer_workspace'), 'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant), - 'description' => 'Open the customer-safe review workspace prefiltered to this tenant.', + 'description' => __('localization.review.customer_workspace_description'), ]; } if ($record->evidenceSnapshot && $record->tenant) { $links[] = [ - 'title' => 'Evidence snapshot', - 'label' => 'View evidence snapshot', + 'title' => __('localization.review.evidence_snapshot'), + 'label' => __('localization.review.view_evidence_snapshot'), 'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant), - 'description' => 'Return to the evidence basis behind this review.', + 'description' => __('localization.review.evidence_snapshot_description'), ]; } diff --git a/apps/platform/app/Filament/System/Pages/Dashboard.php b/apps/platform/app/Filament/System/Pages/Dashboard.php index 2c238340..03534ef6 100644 --- a/apps/platform/app/Filament/System/Pages/Dashboard.php +++ b/apps/platform/app/Filament/System/Pages/Dashboard.php @@ -28,6 +28,11 @@ class Dashboard extends BaseDashboard { public string $window = SystemConsoleWindow::LastDay; + public function getTitle(): string + { + return __('localization.dashboard.system_title'); + } + /** * @param array $parameters */ @@ -109,12 +114,12 @@ protected function getHeaderActions(): array return [ Action::make('set_window') - ->label('Time window') + ->label(__('localization.dashboard.time_window')) ->icon('heroicon-o-clock') ->color('gray') ->form([ Select::make('window') - ->label('Window') + ->label(__('localization.dashboard.window')) ->options(SystemConsoleWindow::options()) ->default($this->window) ->required(), @@ -130,7 +135,7 @@ protected function getHeaderActions(): array }), Action::make('enter_break_glass') - ->label('Enter break-glass mode') + ->label(__('localization.dashboard.enter_break_glass')) ->color('danger') ->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive()) ->requiresConfirmation() @@ -158,13 +163,13 @@ protected function getHeaderActions(): array $breakGlass->start($user, (string) ($data['reason'] ?? '')); Notification::make() - ->title('Recovery mode enabled') + ->title(__('localization.dashboard.recovery_mode_enabled')) ->success() ->send(); }), Action::make('exit_break_glass') - ->label('Exit break-glass') + ->label(__('localization.dashboard.exit_break_glass')) ->color('gray') ->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive()) ->requiresConfirmation() @@ -180,7 +185,7 @@ protected function getHeaderActions(): array $breakGlass->exit($user); Notification::make() - ->title('Recovery mode ended') + ->title(__('localization.dashboard.recovery_mode_ended')) ->success() ->send(); }), diff --git a/apps/platform/app/Http/Controllers/LocalizationController.php b/apps/platform/app/Http/Controllers/LocalizationController.php new file mode 100644 index 00000000..feab7fe3 --- /dev/null +++ b/apps/platform/app/Http/Controllers/LocalizationController.php @@ -0,0 +1,80 @@ +query('plane'); + $context = $request->attributes->get(LocaleResolver::REQUEST_ATTRIBUTE); + + if (is_string($plane) && $plane !== '') { + $context = $resolver->resolve($request, $plane); + } + + return response()->json(is_array($context) ? $context : $resolver->resolve($request)); + } + + public function updateOverride(Request $request): RedirectResponse + { + $locale = LocaleResolver::normalize($request->input('locale')); + + if ($locale === null) { + throw ValidationException::withMessages([ + 'locale' => [__('localization.validation.unsupported_locale')], + ]); + } + + $request->session()->put(LocaleResolver::SESSION_OVERRIDE_KEY, $locale); + App::setLocale($locale); + + return back()->with('status', __('localization.notifications.locale_override_saved')); + } + + public function clearOverride(Request $request, LocaleResolver $resolver): RedirectResponse + { + $request->session()->forget(LocaleResolver::SESSION_OVERRIDE_KEY); + App::setLocale($resolver->resolve($request)['locale']); + + return back()->with('status', __('localization.notifications.locale_override_cleared')); + } + + public function updateUserPreference(Request $request, LocaleResolver $resolver): RedirectResponse + { + $user = $request->user(); + + abort_unless($user instanceof User, Response::HTTP_NOT_FOUND); + + $rawLocale = $request->input('preferred_locale'); + $locale = $rawLocale === null || $rawLocale === '' + ? null + : LocaleResolver::normalize($rawLocale); + + if ($rawLocale !== null && $rawLocale !== '' && $locale === null) { + throw ValidationException::withMessages([ + 'preferred_locale' => [__('localization.validation.unsupported_locale')], + ]); + } + + $user->forceFill(['preferred_locale' => $locale])->save(); + $user->refresh(); + + App::setLocale($resolver->resolve($request)['locale']); + + return back()->with('status', $locale === null + ? __('localization.notifications.user_preference_cleared') + : __('localization.notifications.user_preference_saved')); + } +} diff --git a/apps/platform/app/Http/Middleware/ApplyResolvedLocale.php b/apps/platform/app/Http/Middleware/ApplyResolvedLocale.php new file mode 100644 index 00000000..b07d372c --- /dev/null +++ b/apps/platform/app/Http/Middleware/ApplyResolvedLocale.php @@ -0,0 +1,29 @@ +resolver->resolve($request, $plane); + + App::setLocale($context['locale']); + Carbon::setLocale($context['locale']); + + $request->attributes->set(LocaleResolver::REQUEST_ATTRIBUTE, $context); + + return $next($request); + } +} diff --git a/apps/platform/app/Models/User.php b/apps/platform/app/Models/User.php index ec5d145f..f37f13a4 100644 --- a/apps/platform/app/Models/User.php +++ b/apps/platform/app/Models/User.php @@ -39,6 +39,7 @@ class User extends Authenticatable implements FilamentUser, HasDefaultTenant, Ha 'password', 'entra_tenant_id', 'entra_object_id', + 'preferred_locale', ]; /** diff --git a/apps/platform/app/Providers/Filament/AdminPanelProvider.php b/apps/platform/app/Providers/Filament/AdminPanelProvider.php index 29da07f0..602bfe4f 100644 --- a/apps/platform/app/Providers/Filament/AdminPanelProvider.php +++ b/apps/platform/app/Providers/Filament/AdminPanelProvider.php @@ -79,16 +79,16 @@ public function panel(Panel $panel): Panel ]) ->navigationItems([ WorkspaceOverview::navigationItem(), - NavigationItem::make('Integrations') + NavigationItem::make(fn (): string => __('localization.navigation.integrations')) ->url(fn (): string => route('filament.admin.resources.provider-connections.index')) ->icon('heroicon-o-link') - ->group('Settings') + ->group(fn (): string => __('localization.navigation.settings')) ->sort(15) ->visible(fn (): bool => ProviderConnectionResource::canViewAny()), - NavigationItem::make('Settings') + NavigationItem::make(fn (): string => __('localization.navigation.settings')) ->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin')) ->icon('heroicon-o-cog-6-tooth') - ->group('Settings') + ->group(fn (): string => __('localization.navigation.settings')) ->sort(20) ->visible(function (): bool { $user = auth()->user(); @@ -115,12 +115,12 @@ public function panel(Panel $panel): Panel return $resolver->isMember($user, $workspace) && $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW); }), - NavigationItem::make('Manage workspaces') + NavigationItem::make(fn (): string => __('localization.navigation.manage_workspaces')) ->url(function (): string { return route('filament.admin.resources.workspaces.index'); }) ->icon('heroicon-o-squares-2x2') - ->group('Settings') + ->group(fn (): string => __('localization.navigation.settings')) ->sort(10) ->visible(function (): bool { $user = auth()->user(); @@ -136,15 +136,15 @@ public function panel(Panel $panel): Panel ->whereIn('role', $roles) ->exists(); }), - NavigationItem::make('Operations') + NavigationItem::make(fn (): string => __('localization.navigation.operations')) ->url(fn (): string => route('admin.operations.index')) ->icon('heroicon-o-queue-list') - ->group('Monitoring') + ->group(fn (): string => __('localization.navigation.monitoring')) ->sort(10), - NavigationItem::make('Audit Log') + NavigationItem::make(fn (): string => __('localization.navigation.audit_log')) ->url(fn (): string => route('admin.monitoring.audit-log')) ->icon('heroicon-o-clipboard-document-list') - ->group('Monitoring') + ->group(fn (): string => __('localization.navigation.monitoring')) ->sort(30), ]) ->renderHook( @@ -210,6 +210,7 @@ public function panel(Panel $panel): Panel DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, ]) + ->middleware(['apply-resolved-locale:admin'], isPersistent: true) ->authMiddleware([ Authenticate::class, ]); diff --git a/apps/platform/app/Providers/Filament/SystemPanelProvider.php b/apps/platform/app/Providers/Filament/SystemPanelProvider.php index a217a9a1..864f6524 100644 --- a/apps/platform/app/Providers/Filament/SystemPanelProvider.php +++ b/apps/platform/app/Providers/Filament/SystemPanelProvider.php @@ -42,6 +42,14 @@ public function panel(Panel $panel): Panel PanelsRenderHook::BODY_START, fn () => view('filament.system.components.break-glass-banner')->render(), ) + ->renderHook( + PanelsRenderHook::TOPBAR_START, + fn () => view('filament.partials.locale-switcher', [ + 'plane' => 'system', + 'showPreference' => false, + 'embedded' => false, + ])->render(), + ) ->discoverPages(in: app_path('Filament/System/Pages'), for: 'App\\Filament\\System\\Pages') ->pages([ Dashboard::class, @@ -59,6 +67,7 @@ public function panel(Panel $panel): Panel DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, ]) + ->middleware(['apply-resolved-locale:system'], isPersistent: true) ->authMiddleware([ Authenticate::class, 'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL, diff --git a/apps/platform/app/Providers/Filament/TenantPanelProvider.php b/apps/platform/app/Providers/Filament/TenantPanelProvider.php index 5966ec2e..4db2a8b2 100644 --- a/apps/platform/app/Providers/Filament/TenantPanelProvider.php +++ b/apps/platform/app/Providers/Filament/TenantPanelProvider.php @@ -50,20 +50,20 @@ public function panel(Panel $panel): Panel 'primary' => Color::Indigo, ]) ->navigationItems([ - NavigationItem::make(OperationRunLinks::collectionLabel()) + NavigationItem::make(fn (): string => __('localization.navigation.operations')) ->url(fn (): string => route('admin.operations.index')) ->icon('heroicon-o-queue-list') - ->group('Monitoring') + ->group(fn (): string => __('localization.navigation.monitoring')) ->sort(10), - NavigationItem::make('Alerts') + NavigationItem::make(fn (): string => __('localization.navigation.alerts')) ->url(fn (): string => url('/admin/alerts')) ->icon('heroicon-o-bell-alert') - ->group('Monitoring') + ->group(fn (): string => __('localization.navigation.monitoring')) ->sort(20), - NavigationItem::make('Audit Log') + NavigationItem::make(fn (): string => __('localization.navigation.audit_log')) ->url(fn (): string => route('admin.monitoring.audit-log')) ->icon('heroicon-o-clipboard-document-list') - ->group('Monitoring') + ->group(fn (): string => __('localization.navigation.monitoring')) ->sort(30), ]) ->renderHook( @@ -111,6 +111,7 @@ public function panel(Panel $panel): Panel DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, ]) + ->middleware(['apply-resolved-locale:tenant'], isPersistent: true) ->authMiddleware([ Authenticate::class, ]); diff --git a/apps/platform/app/Services/Localization/LocaleResolver.php b/apps/platform/app/Services/Localization/LocaleResolver.php new file mode 100644 index 00000000..f3ce65e5 --- /dev/null +++ b/apps/platform/app/Services/Localization/LocaleResolver.php @@ -0,0 +1,215 @@ + + */ + private const SUPPORTED_LOCALES = ['en', 'de']; + + public function __construct( + private SettingsResolver $settingsResolver, + private WorkspaceContext $workspaceContext, + ) {} + + /** + * @return list + */ + public static function supportedLocales(): array + { + return self::SUPPORTED_LOCALES; + } + + /** + * @return array + */ + public static function localeOptions(): array + { + return [ + 'en' => __('localization.locales.en'), + 'de' => __('localization.locales.de'), + ]; + } + + public static function isSupported(mixed $locale): bool + { + return self::normalize($locale) !== null; + } + + public static function normalize(mixed $locale): ?string + { + if (! is_string($locale)) { + return null; + } + + $normalized = strtolower(trim($locale)); + + return in_array($normalized, self::SUPPORTED_LOCALES, true) ? $normalized : null; + } + + /** + * @return array{ + * locale: string, + * source: string, + * fallback_locale: string, + * user_preference_locale: ?string, + * workspace_default_locale: ?string, + * machine_artifacts_invariant: true + * } + */ + public function resolve(Request $request, ?string $plane = null): array + { + $plane = $this->normalizePlane($plane, $request); + + $explicitOverride = $this->explicitOverride($request); + $systemDefault = (string) config('app.fallback_locale', 'en'); + + if ($plane === 'system') { + return $this->resolveFromSources( + explicitOverride: $explicitOverride, + userPreference: null, + workspaceDefault: null, + systemDefault: $systemDefault, + includeUserPreference: false, + includeWorkspaceDefault: false, + ); + } + + $user = $request->user(); + $userPreference = $user instanceof User ? $user->preferred_locale : null; + $workspaceDefault = $this->workspaceDefault($request); + + return $this->resolveFromSources( + explicitOverride: $explicitOverride, + userPreference: $userPreference, + workspaceDefault: $workspaceDefault, + systemDefault: $systemDefault, + includeUserPreference: true, + includeWorkspaceDefault: true, + ); + } + + /** + * @return array{ + * locale: string, + * source: string, + * fallback_locale: string, + * user_preference_locale: ?string, + * workspace_default_locale: ?string, + * machine_artifacts_invariant: true + * } + */ + public function resolveFromSources( + mixed $explicitOverride, + mixed $userPreference, + mixed $workspaceDefault, + mixed $systemDefault, + bool $includeUserPreference = true, + bool $includeWorkspaceDefault = true, + ): array { + $fallbackLocale = self::normalize(config('app.fallback_locale', 'en')) ?? 'en'; + + $candidates = [ + self::SOURCE_EXPLICIT_OVERRIDE => self::normalize($explicitOverride), + ]; + + if ($includeUserPreference) { + $candidates[self::SOURCE_USER_PREFERENCE] = self::normalize($userPreference); + } + + if ($includeWorkspaceDefault) { + $candidates[self::SOURCE_WORKSPACE_DEFAULT] = self::normalize($workspaceDefault); + } + + $candidates[self::SOURCE_SYSTEM_DEFAULT] = self::normalize($systemDefault) ?? $fallbackLocale; + + foreach ($candidates as $source => $locale) { + if ($locale !== null) { + return [ + 'locale' => $locale, + 'source' => $source, + 'fallback_locale' => $fallbackLocale, + 'user_preference_locale' => $includeUserPreference ? self::normalize($userPreference) : null, + 'workspace_default_locale' => $includeWorkspaceDefault ? self::normalize($workspaceDefault) : null, + 'machine_artifacts_invariant' => true, + ]; + } + } + + return [ + 'locale' => $fallbackLocale, + 'source' => self::SOURCE_SYSTEM_DEFAULT, + 'fallback_locale' => $fallbackLocale, + 'user_preference_locale' => $includeUserPreference ? self::normalize($userPreference) : null, + 'workspace_default_locale' => $includeWorkspaceDefault ? self::normalize($workspaceDefault) : null, + 'machine_artifacts_invariant' => true, + ]; + } + + private function explicitOverride(Request $request): ?string + { + $queryLocale = self::normalize($request->query('locale')); + + if ($queryLocale !== null) { + return $queryLocale; + } + + if (! $request->hasSession()) { + return null; + } + + return self::normalize($request->session()->get(self::SESSION_OVERRIDE_KEY)); + } + + private function workspaceDefault(Request $request): ?string + { + $workspace = $this->workspaceContext->currentWorkspace($request); + + if (! $workspace instanceof Workspace) { + return null; + } + + return self::normalize($this->settingsResolver->resolveValue( + workspace: $workspace, + domain: self::SETTING_DOMAIN, + key: self::SETTING_DEFAULT_LOCALE, + )); + } + + private function normalizePlane(?string $plane, Request $request): string + { + $plane = strtolower(trim((string) $plane)); + + if (in_array($plane, ['admin', 'tenant', 'system'], true)) { + return $plane; + } + + return $request->is('system', 'system/*') ? 'system' : 'admin'; + } +} diff --git a/apps/platform/app/Support/Settings/SettingsRegistry.php b/apps/platform/app/Support/Settings/SettingsRegistry.php index 48c352b6..dd645b05 100644 --- a/apps/platform/app/Support/Settings/SettingsRegistry.php +++ b/apps/platform/app/Support/Settings/SettingsRegistry.php @@ -4,8 +4,9 @@ namespace App\Support\Settings; -use App\Support\Ai\AiPolicyMode; use App\Models\Finding; +use App\Services\Localization\LocaleResolver; +use App\Support\Ai\AiPolicyMode; use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Entitlements\WorkspacePlanProfileCatalog; @@ -29,6 +30,25 @@ public function __construct() normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)), )); + $this->register(new SettingDefinition( + domain: LocaleResolver::SETTING_DOMAIN, + key: LocaleResolver::SETTING_DEFAULT_LOCALE, + type: 'string', + systemDefault: null, + rules: [ + 'nullable', + 'string', + 'in:'.implode(',', LocaleResolver::supportedLocales()), + ], + normalizer: static function (mixed $value): ?string { + if ($value === null) { + return null; + } + + return LocaleResolver::normalize($value); + }, + )); + $this->register(new SettingDefinition( domain: 'backup', key: 'retention_keep_last_default', diff --git a/apps/platform/bootstrap/app.php b/apps/platform/bootstrap/app.php index fc43bdff..7ee22e95 100644 --- a/apps/platform/bootstrap/app.php +++ b/apps/platform/bootstrap/app.php @@ -4,6 +4,7 @@ use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use App\Http\Middleware\ApplyResolvedLocale; use App\Http\Middleware\SuppressDebugbarForSmokeRequests; use App\Http\Middleware\UseSystemSessionCookieForLivewireRequests; @@ -24,7 +25,12 @@ UseSystemSessionCookieForLivewireRequests::class, ]); + $middleware->web(append: [ + ApplyResolvedLocale::class, + ]); + $middleware->alias([ + 'apply-resolved-locale' => ApplyResolvedLocale::class, 'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class, 'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class, 'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class, diff --git a/apps/platform/database/migrations/2026_04_28_000000_add_preferred_locale_to_users_table.php b/apps/platform/database/migrations/2026_04_28_000000_add_preferred_locale_to_users_table.php new file mode 100644 index 00000000..5da4f016 --- /dev/null +++ b/apps/platform/database/migrations/2026_04_28_000000_add_preferred_locale_to_users_table.php @@ -0,0 +1,25 @@ +string('preferred_locale', 8) + ->nullable() + ->after('last_workspace_id') + ->index(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('preferred_locale'); + }); + } +}; diff --git a/apps/platform/lang/de/baseline-compare.php b/apps/platform/lang/de/baseline-compare.php new file mode 100644 index 00000000..bbc2dfd7 --- /dev/null +++ b/apps/platform/lang/de/baseline-compare.php @@ -0,0 +1,88 @@ + 'Warnung', + 'duplicate_warning_body_plural' => ':count Policies in diesem Tenant verwenden generische Anzeigenamen, dadurch entstehen :ambiguous_count mehrdeutige Subjekte. :app kann sie nicht sicher mit der Baseline abgleichen.', + 'duplicate_warning_body_singular' => ':count Policy in diesem Tenant verwendet einen generischen Anzeigenamen, dadurch entsteht :ambiguous_count mehrdeutiges Subjekt. :app kann es nicht sicher mit der Baseline abgleichen.', + 'stat_assigned_baseline' => 'Zugewiesene Baseline', + 'stat_total_findings' => 'Findings gesamt', + 'stat_last_compared' => 'Zuletzt verglichen', + 'stat_last_compared_never' => 'Nie', + 'stat_error' => 'Fehler', + 'badge_snapshot' => 'Snapshot #:id', + 'badge_coverage_ok' => 'Abdeckung: OK', + 'badge_coverage_warnings' => 'Abdeckung: Warnungen', + 'badge_fidelity' => 'Fidelity: :level', + 'badge_evidence_gaps' => 'Evidence Gaps: :count', + 'evidence_gaps_tooltip' => 'Wichtigste Gaps: :summary', + 'evidence_gap_details_heading' => 'Evidence-Gap-Details', + 'evidence_gap_details_description' => 'Durchsuchen Sie aufgezeichnete Gap-Subjekte nach Grund, Governed Subject, Subjektklasse, Ergebnis, nächster Aktion oder Subject Key, bevor Sie Rohdiagnosen verwenden.', + 'evidence_gap_search_label' => 'Gap-Details suchen', + 'evidence_gap_search_placeholder' => 'Nach Grund, Typ, Klasse, Ergebnis, Aktion oder Subject Key suchen', + 'evidence_gap_search_help' => 'Filtert über Grund, Governed Subject, Subjektklasse, Ergebnis, nächste Aktion und Subject Key.', + 'evidence_gap_bucket_help_ambiguous_match' => 'Mehrere Inventory-Datensätze passten zum gleichen Policy-Subjekt. Prüfen Sie das Mapping.', + 'evidence_gap_bucket_help_policy_record_missing' => 'Der erwartete Policy-Datensatz wurde im Baseline-Snapshot nicht gefunden. Prüfen Sie, ob die Policy im Tenant noch existiert.', + 'evidence_gap_bucket_help_inventory_record_missing' => 'Für diese Subjekte konnte kein Inventory-Datensatz gefunden werden. Prüfen Sie, ob der Inventory Sync aktuell ist.', + 'evidence_gap_bucket_help_foundation_not_policy_backed' => 'Diese Subjekte existieren in der Foundation-Schicht, sind aber nicht durch eine verwaltete Policy abgedeckt. Prüfen Sie, ob eine Policy erstellt werden sollte.', + 'evidence_gap_bucket_help_capture_failed' => 'Evidence Capture ist für diese Subjekte fehlgeschlagen. Wiederholen Sie den Vergleich oder prüfen Sie die Graph-Konnektivität.', + 'evidence_gap_bucket_help_default' => 'Diese Subjekte wurden beim Vergleich markiert. Prüfen Sie die betroffenen Zeilen.', + 'evidence_gap_reason' => 'Grund', + 'evidence_gap_reason_affected' => ':count betroffen', + 'evidence_gap_reason_recorded' => ':count aufgezeichnet', + 'evidence_gap_reason_missing_detail' => ':count ohne Detail', + 'evidence_gap_structural' => 'Strukturell: :count', + 'evidence_gap_operational' => 'Operativ: :count', + 'evidence_gap_transient' => 'Temporär: :count', + 'evidence_gap_bucket_structural' => ':count strukturell', + 'evidence_gap_bucket_operational' => ':count operativ', + 'evidence_gap_bucket_transient' => ':count temporär', + 'evidence_gap_missing_details_title' => 'Für diesen Run wurden keine Detailzeilen aufgezeichnet', + 'evidence_gap_missing_details_body' => 'Evidence Gaps wurden für diesen Compare Run gezählt, aber Details auf Subjektebene wurden nicht gespeichert. Prüfen Sie Rohdiagnosen oder wiederholen Sie den Vergleich.', + 'evidence_gap_missing_reason_body' => ':count betroffene Subjekte wurden für diesen Grund gezählt, aber Detailzeilen wurden nicht aufgezeichnet.', + 'evidence_gap_legacy_title' => 'Legacy-Development-Gap-Payload erkannt', + 'evidence_gap_legacy_body' => 'Dieser Run verwendet noch die retired breite Grundform. Erzeugen Sie den Run neu oder bereinigen Sie alte lokale Development-Payloads.', + 'evidence_gap_diagnostics_heading' => 'Baseline-Compare-Evidence', + 'evidence_gap_diagnostics_description' => 'Rohdiagnosen bleiben für Support und tiefere Fehlersuche nach Operator-Zusammenfassung und Detailansicht verfügbar.', + 'evidence_gap_policy_type' => 'Governed Subject', + 'evidence_gap_subject_class' => 'Subjektklasse', + 'evidence_gap_outcome' => 'Ergebnis', + 'evidence_gap_next_action' => 'Nächste Aktion', + 'evidence_gap_subject_key' => 'Subject Key', + 'evidence_gap_table_empty_heading' => 'Keine aufgezeichneten Gap-Zeilen passen zu dieser Ansicht', + 'evidence_gap_table_empty_description' => 'Passen Sie Suche oder Filter an, um andere betroffene Subjekte zu prüfen.', + 'comparing_indicator' => 'Vergleich läuft...', + 'no_findings_all_clear' => 'Kein bestätigter Drift im letzten Vergleich', + 'no_findings_coverage_warnings' => 'Kein Drift angezeigt, aber Coverage limitiert diesen Vergleich', + 'no_findings_evidence_gaps' => 'Kein Drift angezeigt, aber Evidence Gaps müssen geprüft werden', + 'no_findings_default' => 'Aktuell sind keine Drift Findings sichtbar', + 'coverage_warning_title' => 'Vergleich mit Warnungen abgeschlossen', + 'coverage_unproven_body' => 'Coverage Proof fehlte oder war nicht lesbar. Findings wurden aus Sicherheitsgründen unterdrückt.', + 'coverage_incomplete_body' => 'Findings wurden für :count Policy :types wegen unvollständiger Coverage übersprungen.', + 'coverage_uncovered_label' => 'Nicht abgedeckt: :list', + 'failed_title' => 'Vergleich fehlgeschlagen', + 'failed_body_default' => 'Der letzte Baseline-Vergleich ist fehlgeschlagen. Prüfen Sie die Run-Details oder wiederholen Sie ihn.', + 'critical_drift_title' => 'Kritischer Drift erkannt', + 'critical_drift_body' => 'Der aktuelle Tenant-Zustand weicht von Baseline :profile ab. :count High-Severity :findings erfordern sofortige Aufmerksamkeit.', + 'empty_no_tenant' => 'Kein Tenant ausgewählt', + 'empty_no_assignment' => 'Keine Baseline zugewiesen', + 'empty_no_snapshot' => 'Kein Snapshot verfügbar', + 'findings_description' => 'Die Tenant-Konfiguration weicht vom Baseline-Profil ab.', + 'rbac_summary_title' => 'Intune-RBAC-Rollendefinitionen', + 'rbac_summary_description' => 'Rollenzuweisungen sind in diesem Baseline-Compare-Release nicht enthalten.', + 'rbac_summary_compared' => 'Verglichen', + 'rbac_summary_unchanged' => 'Unverändert', + 'rbac_summary_modified' => 'Geändert', + 'rbac_summary_missing' => 'Fehlend', + 'rbac_summary_unexpected' => 'Unerwartet', + 'no_drift_title' => 'Kein Drift erkannt', + 'no_drift_body' => 'Der letzte Vergleich hat keinen bestätigten Drift für das zugewiesene Baseline-Profil aufgezeichnet.', + 'coverage_warnings_title' => 'Coverage-Warnungen', + 'coverage_warnings_body' => 'Der letzte Vergleich wurde mit Warnungen abgeschlossen und erzeugte keine bestätigten Drift Findings. Aktualisieren Sie Evidence, bevor Sie dies als Entwarnung werten.', + 'idle_title' => 'Bereit zum Vergleich', + 'button_view_run' => 'Run anzeigen', + 'button_view_failed_run' => 'Fehlgeschlagenen Run anzeigen', + 'button_view_findings' => 'Alle Findings anzeigen', + 'button_review_last_run' => 'Letzten Run prüfen', +]; diff --git a/apps/platform/lang/de/findings.php b/apps/platform/lang/de/findings.php new file mode 100644 index 00000000..83ed5fa4 --- /dev/null +++ b/apps/platform/lang/de/findings.php @@ -0,0 +1,31 @@ + [ + 'rbac_role_definition' => 'Intune-RBAC-Rollendefinitions-Drift', + ], + 'subject_types' => [ + 'policy' => 'Policy', + 'intuneRoleDefinition' => 'Intune-RBAC-Rollendefinition', + ], + 'rbac' => [ + 'detail_heading' => 'Intune-RBAC-Rollendefinitions-Drift', + 'detail_subheading' => 'Rollenzuweisungen sind nicht enthalten. RBAC-Restore wird nicht unterstützt.', + 'metadata_only' => 'Nur Metadaten geändert', + 'permission_change' => 'Berechtigung geändert', + 'missing' => 'Im aktuellen Tenant fehlend', + 'unexpected' => 'Unerwartet im aktuellen Tenant', + 'changed_fields' => 'Geänderte Felder', + 'baseline' => 'Baseline', + 'current' => 'Aktuell', + 'absent' => 'Nicht vorhanden', + 'role_source' => 'Rollenquelle', + 'permission_blocks' => 'Berechtigungsblöcke', + 'built_in' => 'Integriert', + 'custom' => 'Benutzerdefiniert', + 'assignments_excluded' => 'Rollenzuweisungen sind in diesem Baseline-Compare-Release nicht enthalten.', + 'restore_unsupported' => 'RBAC-Restore wird in diesem Release nicht unterstützt.', + ], +]; diff --git a/apps/platform/lang/de/localization.php b/apps/platform/lang/de/localization.php new file mode 100644 index 00000000..4827d589 --- /dev/null +++ b/apps/platform/lang/de/localization.php @@ -0,0 +1,230 @@ + [ + 'en' => 'Englisch', + 'de' => 'Deutsch', + ], + 'source' => [ + 'explicit_override' => 'Sitzungsüberschreibung', + 'user_preference' => 'persönliche Einstellung', + 'workspace_default' => 'Workspace-Standard', + 'workspace_override' => 'Workspace-Überschreibung', + 'system_default' => 'Systemstandard', + ], + 'shell' => [ + 'language' => 'Sprache', + 'current_language' => 'Aktuelle Sprache', + 'language_source' => 'Quelle: :source', + 'temporary_override' => 'Temporäre Überschreibung', + 'switch_language' => 'Sprache wechseln', + 'clear_override' => 'Geerbte Sprache verwenden', + 'personal_preference' => 'Persönliche Einstellung', + 'save_preference' => 'Einstellung speichern', + 'inherit_workspace' => 'Workspace-Standard verwenden', + 'workspace' => 'Workspace', + 'choose_workspace' => 'Workspace auswählen', + 'switch_workspace' => 'Workspace wechseln', + 'workspace_home' => 'Workspace-Start', + 'tenant_scope' => 'Tenant-Kontext', + 'select_tenant' => 'Tenant auswählen', + 'selected_tenant' => 'Ausgewählter Tenant', + 'no_tenant_selected' => 'Kein Tenant ausgewählt', + 'switch_tenant' => 'Tenant wechseln', + 'clear_tenant_scope' => 'Tenant-Kontext löschen', + 'context_unavailable' => 'Kontext nicht verfügbar', + 'context_unavailable_workspace' => 'Der angeforderte Kontext konnte nicht wiederhergestellt werden. Die Shell zeigt stattdessen einen gültigen Workspace-Kontext.', + 'context_unavailable_no_workspace' => 'Wählen Sie einen Workspace aus, um mit einem gültigen Admin-Kontext fortzufahren.', + 'no_active_tenants' => 'In diesem Workspace sind keine aktiven Tenants für den Standardbetrieb verfügbar.', + 'view_managed_tenants' => 'Managed Tenants anzeigen', + 'workspace_wide_available' => 'Kein Tenant ausgewählt. Workspace-weite Seiten bleiben verfügbar; ein Tenant setzt nur den normalen aktiven Betriebskontext.', + 'search_tenants' => 'Tenants suchen...', + 'choose_workspace_first' => 'Wählen Sie zuerst einen Workspace aus.', + ], + 'workspace' => [ + 'title' => 'Workspace-Einstellungen', + 'save' => 'Speichern', + 'reset' => 'Zurücksetzen', + 'no_manage_permission' => 'Sie haben keine Berechtigung zum Verwalten der Workspace-Einstellungen.', + 'no_workspace_override' => 'Keine Workspace-Überschreibung zum Zurücksetzen vorhanden.', + 'last_modified_by' => ':description - Zuletzt geändert von :user, :time.', + 'section' => 'Lokalisierung', + 'section_description' => 'Workspace-Standard für Benutzer ohne persönliche Spracheinstellung.', + 'default_locale_label' => 'Standardsprache', + 'default_locale_placeholder' => 'Nicht gesetzt (verwendet Systemstandard)', + 'default_locale_helper_unset' => 'Nicht gesetzt. Effektive Sprache: :locale (:source).', + 'default_locale_helper_set' => 'Effektive Sprache: :locale.', + ], + 'auth' => [ + 'microsoft_not_configured' => 'Microsoft-Anmeldung ist nicht konfiguriert.', + 'sign_in_microsoft' => 'Mit Microsoft anmelden', + 'tenant_admin_membership_required' => 'Tenant-Admin-Zugriff erfordert eine Tenant-Mitgliedschaft.', + ], + 'navigation' => [ + 'findings' => 'Findings', + 'settings' => 'Einstellungen', + 'integrations' => 'Integrationen', + 'manage_workspaces' => 'Workspaces verwalten', + 'operations' => 'Operationen', + 'audit_log' => 'Audit-Log', + 'alerts' => 'Alerts', + 'governance' => 'Governance', + 'monitoring' => 'Monitoring', + 'dashboard' => 'Dashboard', + ], + 'dashboard' => [ + 'tenant_title' => 'Tenant-Dashboard', + 'system_title' => 'System-Dashboard', + 'request_support' => 'Support anfragen', + 'support_request_heading' => 'Support anfragen', + 'support_request_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus bestehenden Datensätzen hinzu.', + 'submit_request' => 'Anfrage senden', + 'included_context' => 'Enthaltener Kontext', + 'severity' => 'Schweregrad', + 'summary' => 'Zusammenfassung', + 'reproduction_notes' => 'Reproduktionshinweise', + 'contact_name' => 'Kontaktname', + 'contact_email' => 'Kontakt-E-Mail', + 'support_request_submitted' => 'Supportanfrage gesendet', + 'open_support_diagnostics' => 'Supportdiagnosen öffnen', + 'support_diagnostics' => 'Supportdiagnosen', + 'support_diagnostics_description' => 'Redaktionell bereinigter Tenant-Kontext aus bestehenden Datensätzen.', + 'close' => 'Schließen', + 'time_window' => 'Zeitfenster', + 'window' => 'Fenster', + 'enter_break_glass' => 'Break-Glass-Modus aktivieren', + 'exit_break_glass' => 'Break-Glass beenden', + 'recovery_mode_enabled' => 'Wiederherstellungsmodus aktiviert', + 'recovery_mode_ended' => 'Wiederherstellungsmodus beendet', + ], + 'review' => [ + 'reporting' => 'Berichte', + 'customer_reviews' => 'Kundenreviews', + 'customer_review_workspace' => 'Kundenreview-Workspace', + 'customer_safe_review_workspace' => 'Kundensicherer Review-Workspace', + 'customer_workspace_intro' => 'Prüfen Sie den zuletzt veröffentlichten kundensicheren Status für jeden berechtigten Tenant, ohne den aktuellen Workspace-Kontext zu verlassen.', + 'customer_workspace_canonical_note' => 'Eine Zeile öffnet die bestehende Tenant-Review-Detailseite, damit Evidence, Review-Packs und auditfähige Nachweise auf ihren kanonischen tenantbezogenen Oberflächen bleiben.', + 'reviews' => 'Reviews', + 'clear_filters' => 'Filter löschen', + 'tenant' => 'Tenant', + 'latest_review' => 'Letztes Review', + 'key_findings' => 'Wichtige Findings', + 'accepted_risks' => 'Akzeptierte Risiken', + 'published' => 'Veröffentlicht', + 'review_pack' => 'Review-Pack', + 'open_latest_review' => 'Letztes Review öffnen', + 'download_review_pack' => 'Review-Pack herunterladen', + 'no_entitled_tenants' => 'Keine berechtigten Tenants passen zu dieser Ansicht', + 'clear_filters_description' => 'Löschen Sie die aktuellen Filter, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.', + 'adjust_filters_description' => 'Passen Sie die Filter an, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.', + 'no_published_review' => 'Kein veröffentlichtes Review', + 'no_published_review_available' => 'Noch kein veröffentlichtes Review verfügbar', + 'no_findings_recorded' => 'Im veröffentlichten Review sind keine Findings erfasst.', + 'findings_count_summary' => ':count Findings im veröffentlichten Review zusammengefasst.', + 'findings_count_with_outcomes' => ':count Findings. Terminale Ergebnisse: :outcomes.', + 'no_accepted_risks_recorded' => 'Keine akzeptierten Risiken erfasst.', + 'accepted_risks_need_follow_up' => ':warnings akzeptierte Risiken benötigen Governance-Nacharbeit (:total gesamt).', + 'accepted_risks_governed' => ':count akzeptierte Risiken sind governed.', + 'accepted_risks_on_record' => ':count akzeptierte Risiken sind erfasst.', + 'unavailable' => 'Nicht verfügbar', + 'available' => 'Verfügbar', + 'outcome_summary' => 'Ergebniszusammenfassung', + 'review' => 'Review', + 'review_date' => 'Review-Datum', + 'completeness' => 'Vollständigkeit', + 'evidence_snapshot' => 'Evidence-Snapshot', + 'current_export' => 'Aktueller Export', + 'executive_posture' => 'Executive-Status', + 'sections' => 'Abschnitte', + 'details' => 'Details', + 'export_executive_pack' => 'Executive-Pack exportieren', + 'outcome' => 'Ergebnis', + 'export' => 'Export', + 'next_step' => 'Nächster Schritt', + 'no_tenant_reviews_yet' => 'Noch keine Tenant-Reviews', + 'create_first_review_description' => 'Erstellen Sie das erste Review aus einem verankerten Evidence-Snapshot, um die wiederkehrende Review-Historie für diesen Tenant zu starten.', + 'create_first_review' => 'Erstes Review erstellen', + 'create_review' => 'Review erstellen', + 'evidence_basis' => 'Evidence-Basis', + 'evidence_basis_helper' => 'Wählen Sie den verankerten Evidence-Snapshot für dieses Review.', + 'unable_create_missing_context' => 'Review kann nicht erstellt werden - Kontext fehlt.', + 'select_valid_evidence_snapshot' => 'Wählen Sie einen gültigen Evidence-Snapshot aus.', + 'unable_create_review' => 'Review kann nicht erstellt werden', + 'review_already_available' => 'Review bereits verfügbar', + 'review_already_available_body' => 'Ein passendes veränderbares Review ist für diese Evidence-Basis bereits vorhanden.', + 'view_review' => 'Review anzeigen', + 'open_operation' => 'Operation öffnen', + 'review_composing_background' => 'Das Review wird im Hintergrund zusammengestellt.', + 'unable_export_missing_context' => 'Review kann nicht exportiert werden - Kontext fehlt.', + 'export_already_queued_body' => 'Ein Executive-Pack-Export ist für dieses Review bereits eingereiht oder läuft.', + 'executive_pack_export_unavailable' => 'Executive-Pack-Export nicht verfügbar', + 'unable_export_executive_pack' => 'Executive-Pack kann nicht exportiert werden', + 'executive_pack_already_available' => 'Executive-Pack bereits verfügbar', + 'executive_pack_already_available_body' => 'Ein passendes Executive-Pack ist für dieses Review bereits vorhanden.', + 'view_pack' => 'Pack anzeigen', + 'executive_pack_generating_background' => 'Das Executive-Pack wird im Hintergrund erstellt.', + 'review_explanation' => 'Review-Erklärung', + 'reason_owner' => 'Reason Owner', + 'platform_core' => 'Platform Core', + 'platform_reason_family' => 'Platform-Reason-Familie', + 'compatibility' => 'Kompatibilität', + 'highlights' => 'Highlights', + 'next_actions' => 'Nächste Aktionen', + 'related_context' => 'Verwandter Kontext', + 'publication_readiness' => 'Veröffentlichungsreife', + 'ready_for_publication' => 'Dieses Review ist bereit für Veröffentlichung und Executive-Pack-Export.', + 'internal_only' => 'Dieses Review ist aktuell nur für interne Nutzung geeignet.', + 'needs_follow_up' => 'Dieses Review benötigt vor der Veröffentlichung noch Nacharbeit.', + 'key_entries' => 'Wichtige Einträge', + 'entry' => 'Eintrag', + 'follow_up' => 'Follow-up', + 'diagnostics' => 'Diagnosen', + 'result_meaning' => 'Ergebnisbedeutung', + 'result_trust' => 'Ergebnisvertrauen', + 'artifact_truth' => 'Artifact Truth', + 'no_action_needed' => 'Keine Aktion erforderlich', + 'count' => 'Anzahl', + 'guidance' => 'Orientierung', + 'findings' => 'Findings', + 'reports' => 'Berichte', + 'operations' => 'Operationen', + 'pending_verification' => 'Verifizierung ausstehend', + 'verified_cleared' => 'Verifiziert bereinigt', + 'terminal_outcomes' => 'Terminale Ergebnisse', + 'pending' => 'Ausstehend', + 'operation' => 'Operation', + 'operation_description' => 'Prüfen Sie die letzte Review-Zusammenstellung oder den Aktualisierungslauf.', + 'executive_pack' => 'Executive-Pack', + 'view_executive_pack' => 'Executive-Pack anzeigen', + 'executive_pack_description' => 'Öffnet den aktuellen Export, der zu diesem Review gehört.', + 'customer_workspace' => 'Kunden-Workspace', + 'open_customer_workspace' => 'Kunden-Workspace öffnen', + 'customer_workspace_description' => 'Öffnet den kundensicheren Review-Workspace mit Filter auf diesen Tenant.', + 'view_evidence_snapshot' => 'Evidence-Snapshot anzeigen', + 'evidence_snapshot_description' => 'Zur Evidence-Basis hinter diesem Review zurückkehren.', + ], + 'findings' => [ + 'all' => 'Alle', + 'needs_action' => 'Handlungsbedarf', + 'overdue' => 'Überfällig', + 'risk_accepted' => 'Risiko akzeptiert', + 'resolved' => 'Gelöst', + 'actions' => 'Aktionen', + 'open_approval_queue' => 'Freigabewarteschlange öffnen', + ], + 'notifications' => [ + 'locale_override_saved' => 'Sprachüberschreibung angewendet.', + 'locale_override_cleared' => 'Sprachüberschreibung gelöscht.', + 'user_preference_saved' => 'Spracheinstellung gespeichert.', + 'user_preference_cleared' => 'Spracheinstellung gelöscht.', + 'workspace_settings_saved' => 'Workspace-Einstellungen gespeichert', + 'workspace_settings_unchanged' => 'Keine Einstellungsänderungen zu speichern', + 'workspace_setting_reset' => 'Workspace-Einstellung auf Standard zurückgesetzt', + 'setting_already_default' => 'Einstellung verwendet bereits den Standard', + ], + 'validation' => [ + 'unsupported_locale' => 'Wählen Sie eine unterstützte Sprache.', + ], +]; diff --git a/apps/platform/lang/en/localization.php b/apps/platform/lang/en/localization.php new file mode 100644 index 00000000..8d0869b9 --- /dev/null +++ b/apps/platform/lang/en/localization.php @@ -0,0 +1,230 @@ + [ + 'en' => 'English', + 'de' => 'German', + ], + 'source' => [ + 'explicit_override' => 'session override', + 'user_preference' => 'personal preference', + 'workspace_default' => 'workspace default', + 'workspace_override' => 'workspace override', + 'system_default' => 'system default', + ], + 'shell' => [ + 'language' => 'Language', + 'current_language' => 'Current language', + 'language_source' => 'Source: :source', + 'temporary_override' => 'Temporary override', + 'switch_language' => 'Switch language', + 'clear_override' => 'Use inherited language', + 'personal_preference' => 'Personal preference', + 'save_preference' => 'Save preference', + 'inherit_workspace' => 'Use workspace default', + 'workspace' => 'Workspace', + 'choose_workspace' => 'Choose workspace', + 'switch_workspace' => 'Switch workspace', + 'workspace_home' => 'Workspace Home', + 'tenant_scope' => 'Tenant scope', + 'select_tenant' => 'Select tenant', + 'selected_tenant' => 'Selected tenant', + 'no_tenant_selected' => 'No tenant selected', + 'switch_tenant' => 'Switch tenant', + 'clear_tenant_scope' => 'Clear tenant scope', + 'context_unavailable' => 'Context unavailable', + 'context_unavailable_workspace' => 'The requested scope could not be restored. The shell is showing a valid workspace state instead.', + 'context_unavailable_no_workspace' => 'Choose a workspace to continue with a valid admin context.', + 'no_active_tenants' => 'No active tenants are available for the standard operating context in this workspace.', + 'view_managed_tenants' => 'View managed tenants', + 'workspace_wide_available' => 'No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context.', + 'search_tenants' => 'Search tenants...', + 'choose_workspace_first' => 'Choose a workspace first.', + ], + 'workspace' => [ + 'title' => 'Workspace settings', + 'save' => 'Save', + 'reset' => 'Reset', + 'no_manage_permission' => 'You do not have permission to manage workspace settings.', + 'no_workspace_override' => 'No workspace override to reset.', + 'last_modified_by' => ':description - Last modified by :user, :time.', + 'section' => 'Localization settings', + 'section_description' => 'Workspace default used by users without a personal language preference.', + 'default_locale_label' => 'Default language', + 'default_locale_placeholder' => 'Unset (uses system default)', + 'default_locale_helper_unset' => 'Unset. Effective language: :locale (:source).', + 'default_locale_helper_set' => 'Effective language: :locale.', + ], + 'auth' => [ + 'microsoft_not_configured' => 'Microsoft sign-in is not configured.', + 'sign_in_microsoft' => 'Sign in with Microsoft', + 'tenant_admin_membership_required' => 'Tenant Admin access requires a tenant membership.', + ], + 'navigation' => [ + 'findings' => 'Findings', + 'settings' => 'Settings', + 'integrations' => 'Integrations', + 'manage_workspaces' => 'Manage workspaces', + 'operations' => 'Operations', + 'audit_log' => 'Audit Log', + 'alerts' => 'Alerts', + 'governance' => 'Governance', + 'monitoring' => 'Monitoring', + 'dashboard' => 'Dashboard', + ], + 'dashboard' => [ + 'tenant_title' => 'Tenant dashboard', + 'system_title' => 'System dashboard', + 'request_support' => 'Request support', + 'support_request_heading' => 'Request support', + 'support_request_description' => 'Share a concise summary and TenantAtlas will attach redacted context from existing records.', + 'submit_request' => 'Submit request', + 'included_context' => 'Included context', + 'severity' => 'Severity', + 'summary' => 'Summary', + 'reproduction_notes' => 'Reproduction notes', + 'contact_name' => 'Contact name', + 'contact_email' => 'Contact email', + 'support_request_submitted' => 'Support request submitted', + 'open_support_diagnostics' => 'Open support diagnostics', + 'support_diagnostics' => 'Support diagnostics', + 'support_diagnostics_description' => 'Redacted tenant context from existing records.', + 'close' => 'Close', + 'time_window' => 'Time window', + 'window' => 'Window', + 'enter_break_glass' => 'Enter break-glass mode', + 'exit_break_glass' => 'Exit break-glass', + 'recovery_mode_enabled' => 'Recovery mode enabled', + 'recovery_mode_ended' => 'Recovery mode ended', + ], + 'review' => [ + 'reporting' => 'Reporting', + 'customer_reviews' => 'Customer reviews', + 'customer_review_workspace' => 'Customer Review Workspace', + 'customer_safe_review_workspace' => 'Customer-safe review workspace', + 'customer_workspace_intro' => 'Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context.', + 'customer_workspace_canonical_note' => 'Opening a row returns to the existing tenant review detail so evidence, review packs, and audit-aware proof remain on their canonical tenant-scoped surfaces.', + 'reviews' => 'Reviews', + 'clear_filters' => 'Clear filters', + 'tenant' => 'Tenant', + 'latest_review' => 'Latest review', + 'key_findings' => 'Key findings', + 'accepted_risks' => 'Accepted risks', + 'published' => 'Published', + 'review_pack' => 'Review pack', + 'open_latest_review' => 'Open latest review', + 'download_review_pack' => 'Download review pack', + 'no_entitled_tenants' => 'No entitled tenants match this view', + 'clear_filters_description' => 'Clear the current filters to return to the full customer review workspace for your entitled tenants.', + 'adjust_filters_description' => 'Adjust filters to return to the full customer review workspace for your entitled tenants.', + 'no_published_review' => 'No published review', + 'no_published_review_available' => 'No published review available yet', + 'no_findings_recorded' => 'No findings recorded in the published review.', + 'findings_count_summary' => ':count findings summarized in the published review.', + 'findings_count_with_outcomes' => ':count findings. Terminal outcomes: :outcomes.', + 'no_accepted_risks_recorded' => 'No accepted risks recorded.', + 'accepted_risks_need_follow_up' => ':warnings accepted risks need governance follow-up (:total total).', + 'accepted_risks_governed' => ':count accepted risks are governed.', + 'accepted_risks_on_record' => ':count accepted risks are on record.', + 'unavailable' => 'Unavailable', + 'available' => 'Available', + 'outcome_summary' => 'Outcome summary', + 'review' => 'Review', + 'review_date' => 'Review date', + 'completeness' => 'Completeness', + 'evidence_snapshot' => 'Evidence snapshot', + 'current_export' => 'Current export', + 'executive_posture' => 'Executive posture', + 'sections' => 'Sections', + 'details' => 'Details', + 'export_executive_pack' => 'Export executive pack', + 'outcome' => 'Outcome', + 'export' => 'Export', + 'next_step' => 'Next step', + 'no_tenant_reviews_yet' => 'No tenant reviews yet', + 'create_first_review_description' => 'Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.', + 'create_first_review' => 'Create first review', + 'create_review' => 'Create review', + 'evidence_basis' => 'Evidence basis', + 'evidence_basis_helper' => 'Choose the anchored evidence snapshot for this review.', + 'unable_create_missing_context' => 'Unable to create review - missing context.', + 'select_valid_evidence_snapshot' => 'Select a valid evidence snapshot.', + 'unable_create_review' => 'Unable to create review', + 'review_already_available' => 'Review already available', + 'review_already_available_body' => 'A matching mutable review already exists for this evidence basis.', + 'view_review' => 'View review', + 'open_operation' => 'Open operation', + 'review_composing_background' => 'The review is being composed in the background.', + 'unable_export_missing_context' => 'Unable to export review - missing context.', + 'export_already_queued_body' => 'An executive pack export is already queued or running for this review.', + 'executive_pack_export_unavailable' => 'Executive pack export unavailable', + 'unable_export_executive_pack' => 'Unable to export executive pack', + 'executive_pack_already_available' => 'Executive pack already available', + 'executive_pack_already_available_body' => 'A matching executive pack already exists for this review.', + 'view_pack' => 'View pack', + 'executive_pack_generating_background' => 'The executive pack is being generated in the background.', + 'review_explanation' => 'Review explanation', + 'reason_owner' => 'Reason owner', + 'platform_core' => 'Platform core', + 'platform_reason_family' => 'Platform reason family', + 'compatibility' => 'Compatibility', + 'highlights' => 'Highlights', + 'next_actions' => 'Next actions', + 'related_context' => 'Related context', + 'publication_readiness' => 'Publication readiness', + 'ready_for_publication' => 'This review is ready for publication and executive-pack export.', + 'internal_only' => 'This review is currently safe for internal use only.', + 'needs_follow_up' => 'This review still needs follow-up before publication.', + 'key_entries' => 'Key entries', + 'entry' => 'Entry', + 'follow_up' => 'Follow-up', + 'diagnostics' => 'Diagnostics', + 'result_meaning' => 'Result meaning', + 'result_trust' => 'Result trust', + 'artifact_truth' => 'Artifact truth', + 'no_action_needed' => 'No action needed', + 'count' => 'Count', + 'guidance' => 'Guidance', + 'findings' => 'Findings', + 'reports' => 'Reports', + 'operations' => 'Operations', + 'pending_verification' => 'Pending verification', + 'verified_cleared' => 'Verified cleared', + 'terminal_outcomes' => 'Terminal outcomes', + 'pending' => 'Pending', + 'operation' => 'Operation', + 'operation_description' => 'Inspect the latest review composition or refresh run.', + 'executive_pack' => 'Executive pack', + 'view_executive_pack' => 'View executive pack', + 'executive_pack_description' => 'Open the current export that belongs to this review.', + 'customer_workspace' => 'Customer workspace', + 'open_customer_workspace' => 'Open customer workspace', + 'customer_workspace_description' => 'Open the customer-safe review workspace prefiltered to this tenant.', + 'view_evidence_snapshot' => 'View evidence snapshot', + 'evidence_snapshot_description' => 'Return to the evidence basis behind this review.', + ], + 'findings' => [ + 'all' => 'All', + 'needs_action' => 'Needs action', + 'overdue' => 'Overdue', + 'risk_accepted' => 'Risk accepted', + 'resolved' => 'Resolved', + 'actions' => 'Actions', + 'open_approval_queue' => 'Open approval queue', + ], + 'notifications' => [ + 'locale_override_saved' => 'Language override applied.', + 'locale_override_cleared' => 'Language override cleared.', + 'user_preference_saved' => 'Language preference saved.', + 'user_preference_cleared' => 'Language preference cleared.', + 'workspace_settings_saved' => 'Workspace settings saved', + 'workspace_settings_unchanged' => 'No settings changes to save', + 'workspace_setting_reset' => 'Workspace setting reset to default', + 'setting_already_default' => 'Setting already uses default', + ], + 'validation' => [ + 'unsupported_locale' => 'Choose a supported language.', + ], +]; diff --git a/apps/platform/resources/views/filament/infolists/entries/governance-artifact-truth.blade.php b/apps/platform/resources/views/filament/infolists/entries/governance-artifact-truth.blade.php index 17228e48..db80eafe 100644 --- a/apps/platform/resources/views/filament/infolists/entries/governance-artifact-truth.blade.php +++ b/apps/platform/resources/views/filament/infolists/entries/governance-artifact-truth.blade.php @@ -37,7 +37,7 @@ $compressedOutcome['primaryLabel'] ?? null, $state['primaryLabel'] ?? null, $operatorExplanation['headline'] ?? null, - 'Artifact truth', + __('localization.review.artifact_truth'), ]); $primaryReason = $firstArtifactTruthText([ $compressedOutcome['primaryReason'] ?? null, @@ -49,7 +49,7 @@ $compressedOutcome['nextActionText'] ?? null, data_get($operatorExplanation, 'nextAction.text'), $state['nextActionLabel'] ?? null, - 'No action needed', + __('localization.review.no_action_needed'), ]); $diagnosticsSummary = $firstArtifactTruthText([ $compressedOutcome['diagnosticsSummary'] ?? null, @@ -81,7 +81,7 @@ if ($evaluationSpec && $evaluationSpec->label !== 'Unknown') { $summaryFacts->push([ - 'label' => 'Result meaning', + 'label' => __('localization.review.result_meaning'), 'value' => $evaluationSpec->label, 'badge' => BadgeCatalog::summaryData($evaluationSpec), ]); @@ -89,7 +89,7 @@ if ($trustSpec && $trustSpec->label !== 'Unknown') { $summaryFacts->push([ - 'label' => 'Result trust', + 'label' => __('localization.review.result_trust'), 'value' => $trustSpec->label, 'badge' => BadgeCatalog::summaryData($trustSpec), ]); @@ -133,7 +133,7 @@
- Diagnostics + {{ __('localization.review.diagnostics') }}
@@ -164,7 +164,7 @@
- {{ $count['label'] ?? 'Count' }} + {{ $count['label'] ?? __('localization.review.count') }}
{{ (int) ($count['value'] ?? 0) }} @@ -211,7 +211,7 @@
-
Next step
+
{{ __('localization.review.next_step') }}
{{ $nextActionText }}
@@ -237,7 +237,7 @@ @if ($nextSteps !== [])
-
Guidance
+
{{ __('localization.review.guidance') }}
    @foreach ($nextSteps as $step) @continue(! is_string($step) || trim($step) === '') diff --git a/apps/platform/resources/views/filament/infolists/entries/tenant-review-section.blade.php b/apps/platform/resources/views/filament/infolists/entries/tenant-review-section.blade.php index 5559a0b5..24079816 100644 --- a/apps/platform/resources/views/filament/infolists/entries/tenant-review-section.blade.php +++ b/apps/platform/resources/views/filament/infolists/entries/tenant-review-section.blade.php @@ -42,14 +42,14 @@ @if ($entries !== [])
    -
    Key entries
    +
    {{ __('localization.review.key_entries') }}
    @foreach ($entries as $entry) @continue(! is_array($entry))
    - {{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? 'Entry' }} + {{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? __('localization.review.entry') }}
    @php @@ -82,7 +82,7 @@ @if ($nextActions !== [])
    -
    Follow-up
    +
    {{ __('localization.review.follow_up') }}
      @foreach ($nextActions as $action) @continue(! is_string($action) || trim($action) === '') diff --git a/apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php b/apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php index 7a46d57d..ff3a1169 100644 --- a/apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php +++ b/apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php @@ -25,7 +25,7 @@ @if ($operatorExplanation !== [])
      - {{ $operatorExplanation['headline'] ?? 'Review explanation' }} + {{ $operatorExplanation['headline'] ?? __('localization.review.review_explanation') }}
      @if (filled($operatorExplanation['reliabilityStatement'] ?? null)) @@ -45,13 +45,13 @@ @if ($reasonSemantics !== [])
      -
      Reason owner
      -
      {{ $reasonSemantics['owner_label'] ?? 'Platform core' }}
      +
      {{ __('localization.review.reason_owner') }}
      +
      {{ $reasonSemantics['owner_label'] ?? __('localization.review.platform_core') }}
      -
      Platform reason family
      -
      {{ $reasonSemantics['family_label'] ?? 'Compatibility' }}
      +
      {{ __('localization.review.platform_reason_family') }}
      +
      {{ $reasonSemantics['family_label'] ?? __('localization.review.compatibility') }}
      @endif @@ -74,7 +74,7 @@ @if ($highlights !== [])
      -
      Highlights
      +
      {{ __('localization.review.highlights') }}
        @foreach ($highlights as $highlight) @continue(! is_string($highlight) || trim($highlight) === '') @@ -87,7 +87,7 @@ @if ($nextActions !== [])
        -
        Next actions
        +
        {{ __('localization.review.next_actions') }}
          @foreach ($nextActions as $action) @continue(! is_string($action) || trim($action) === '') @@ -100,7 +100,7 @@ @if ($contextLinks !== [])
          -
          Related context
          +
          {{ __('localization.review.related_context') }}
          @foreach ($contextLinks as $link) @php @@ -130,11 +130,11 @@ @endif
          -
          Publication readiness
          +
          {{ __('localization.review.publication_readiness') }}
          @if ($publishBlockers === [] && $decisionDirection === 'publishable')
          - This review is ready for publication and executive-pack export. + {{ __('localization.review.ready_for_publication') }}
          @elseif ($publishBlockers !== [])
            @@ -146,7 +146,7 @@
          @elseif ($decisionDirection === 'internal_only')
          -
          This review is currently safe for internal use only.
          +
          {{ __('localization.review.internal_only') }}
          @if ($publicationNextAction !== null)
          {{ $publicationNextAction }}
          @@ -154,7 +154,7 @@
          @else
          - {{ $publicationNextAction ?? $publicationReason ?? 'This review still needs follow-up before publication.' }} + {{ $publicationNextAction ?? $publicationReason ?? __('localization.review.needs_follow_up') }}
          @endif
          diff --git a/apps/platform/resources/views/filament/pages/auth/login.blade.php b/apps/platform/resources/views/filament/pages/auth/login.blade.php index 777da10c..a2c3d4fe 100644 --- a/apps/platform/resources/views/filament/pages/auth/login.blade.php +++ b/apps/platform/resources/views/filament/pages/auth/login.blade.php @@ -14,7 +14,7 @@ @if (! $isConfigured)
          - Microsoft sign-in is not configured. + {{ __('localization.auth.microsoft_not_configured') }}
          @endif @@ -25,11 +25,11 @@ :disabled="! $isConfigured" color="primary" > - Sign in with Microsoft + {{ __('localization.auth.sign_in_microsoft') }}
          - Tenant Admin access requires a tenant membership. + {{ __('localization.auth.tenant_admin_membership_required') }}
          diff --git a/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php b/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php index b9a5f6b2..81154556 100644 --- a/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php +++ b/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php @@ -2,18 +2,18 @@
          - Customer-safe review workspace + {{ __('localization.review.customer_safe_review_workspace') }}
          - Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context. + {{ __('localization.review.customer_workspace_intro') }}
          - Opening a row returns to the existing tenant review detail so evidence, review packs, and audit-aware proof remain on their canonical tenant-scoped surfaces. + {{ __('localization.review.customer_workspace_canonical_note') }}
          {{ $this->table }} - \ No newline at end of file + diff --git a/apps/platform/resources/views/filament/partials/context-bar.blade.php b/apps/platform/resources/views/filament/partials/context-bar.blade.php index 28f3e44f..2216dafb 100644 --- a/apps/platform/resources/views/filament/partials/context-bar.blade.php +++ b/apps/platform/resources/views/filament/partials/context-bar.blade.php @@ -31,8 +31,8 @@ @endphp @php - $tenantLabel = $currentTenantName ?? 'No tenant selected'; - $workspaceLabel = $workspace?->name ?? 'Choose workspace'; + $tenantLabel = $currentTenantName ?? __('localization.shell.no_tenant_selected'); + $workspaceLabel = $workspace?->name ?? __('localization.shell.choose_workspace'); $hasActiveTenant = $currentTenantName !== null; $managedTenantsUrl = $workspace ? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]) @@ -40,7 +40,8 @@ $workspaceUrl = $workspace ? route('admin.home') : ChooseWorkspace::getUrl(panel: 'admin'); - $tenantTriggerLabel = $workspace ? $tenantLabel : 'Choose workspace'; + $tenantTriggerLabel = $workspace ? $tenantLabel : __('localization.shell.choose_workspace'); + $localePlane = Filament::getCurrentPanel()?->getId() === 'tenant' ? 'tenant' : 'admin'; @endphp
          @@ -63,7 +64,7 @@ class="inline-flex items-center gap-1.5 rounded-l-lg px-2.5 py-1.5 font-medium t @endif @@ -154,23 +155,23 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark: @else @if ($tenants->isEmpty())
          -
          No active tenants are available for the standard operating context in this workspace.
          +
          {{ __('localization.shell.no_active_tenants') }}
          - View managed tenants + {{ __('localization.shell.view_managed_tenants') }}
          @else @if (! $hasActiveTenant)
          - No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context. + {{ __('localization.shell.workspace_wide_available') }}
          @endif @@ -207,7 +208,7 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{ @csrf @endif @@ -216,10 +217,12 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{
          @else
          - Choose a workspace first. + {{ __('localization.shell.choose_workspace_first') }}
          @endif
        + + @include('filament.partials.locale-switcher', ['plane' => $localePlane, 'showPreference' => true, 'embedded' => true])
      diff --git a/apps/platform/resources/views/filament/partials/locale-switcher.blade.php b/apps/platform/resources/views/filament/partials/locale-switcher.blade.php new file mode 100644 index 00000000..37ba9f92 --- /dev/null +++ b/apps/platform/resources/views/filament/partials/locale-switcher.blade.php @@ -0,0 +1,110 @@ +@php + use App\Models\User; + use App\Services\Localization\LocaleResolver; + + $plane = $plane ?? 'admin'; + $showPreference = (bool) ($showPreference ?? true); + $embedded = (bool) ($embedded ?? false); + + /** @var LocaleResolver $localeResolver */ + $localeResolver = app(LocaleResolver::class); + $localeContext = request()->attributes->get(LocaleResolver::REQUEST_ATTRIBUTE); + $localeContext = is_array($localeContext) ? $localeContext : $localeResolver->resolve(request(), $plane); + $localeOptions = LocaleResolver::localeOptions(); + $currentLocale = (string) ($localeContext['locale'] ?? 'en'); + $source = (string) ($localeContext['source'] ?? LocaleResolver::SOURCE_SYSTEM_DEFAULT); + $sourceLabel = __('localization.source.'.$source); + $user = auth()->user(); + $preferredLocale = $user instanceof User ? $user->preferred_locale : null; +@endphp + +
      + + + + + + +
      +
      +
      + {{ __('localization.shell.current_language') }} +
      +
      +
      + {{ $localeOptions[$currentLocale] ?? strtoupper($currentLocale) }} +
      +
      + {{ __('localization.shell.language_source', ['source' => $sourceLabel]) }} +
      +
      +
      + +
      + +
      + @csrf + + + + @foreach ($localeOptions as $locale => $label) + + @endforeach + + + +
      + + @if ($source === LocaleResolver::SOURCE_EXPLICIT_OVERRIDE) +
      + @csrf + @method('DELETE') + +
      + @endif + + @if ($showPreference && $user instanceof User) +
      + +
      + @csrf + + + + + @foreach ($localeOptions as $locale => $label) + + @endforeach + + + +
      + @endif +
      +
      +
      +
      diff --git a/apps/platform/routes/web.php b/apps/platform/routes/web.php index 394db8a8..0435c31a 100644 --- a/apps/platform/routes/web.php +++ b/apps/platform/routes/web.php @@ -4,6 +4,7 @@ use App\Http\Controllers\AdminConsentCallbackController; use App\Http\Controllers\Auth\EntraController; use App\Http\Controllers\ClearTenantContextController; +use App\Http\Controllers\LocalizationController; use App\Http\Controllers\OpenFindingExceptionsQueueController; use App\Http\Controllers\RbacDelegatedAuthController; use App\Http\Controllers\ReviewPackDownloadController; @@ -67,6 +68,21 @@ ->middleware('throttle:entra-callback') ->name('auth.entra.callback'); +Route::middleware(['web'])->group(function (): void { + Route::get('/localization/context', [LocalizationController::class, 'context']) + ->name('localization.context'); + + Route::post('/localization/override', [LocalizationController::class, 'updateOverride']) + ->name('localization.override.update'); + + Route::delete('/localization/override', [LocalizationController::class, 'clearOverride']) + ->name('localization.override.clear'); +}); + +Route::middleware(['web', 'auth', 'ensure-correct-guard:web']) + ->post('/users/me/locale-preference', [LocalizationController::class, 'updateUserPreference']) + ->name('localization.preference.update'); + $makeSmokeCookie = static fn () => cookie()->make( SuppressDebugbarForSmokeRequests::COOKIE_NAME, SuppressDebugbarForSmokeRequests::COOKIE_VALUE, diff --git a/apps/platform/tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php b/apps/platform/tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php new file mode 100644 index 00000000..5526d811 --- /dev/null +++ b/apps/platform/tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php @@ -0,0 +1,16 @@ +toBe('Tenant-Dashboard') + ->and(FindingResource::getNavigationGroup())->toBe('Governance') + ->and(__('localization.findings.needs_action'))->toBe('Handlungsbedarf') + ->and(__('baseline-compare.stat_total_findings'))->toBe('Findings gesamt') + ->and(__('findings.rbac.detail_heading'))->toBe('Intune-RBAC-Rollendefinitions-Drift'); +}); diff --git a/apps/platform/tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php b/apps/platform/tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php new file mode 100644 index 00000000..872a4286 --- /dev/null +++ b/apps/platform/tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php @@ -0,0 +1,43 @@ +withSession([LocaleResolver::SESSION_OVERRIDE_KEY => 'de']) + ->get('/admin/login') + ->assertSuccessful() + ->assertSee('Mit Microsoft anmelden') + ->assertSee('Tenant-Admin-Zugriff erfordert eine Tenant-Mitgliedschaft'); +}); + +it('keeps system plane resolution independent from user and workspace preferences', function (): void { + [$workspace, $user] = localizationWorkspaceMember(); + + $user->forceFill(['preferred_locale' => 'de'])->save(); + session()->forget(LocaleResolver::SESSION_OVERRIDE_KEY); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + LocaleResolver::SESSION_OVERRIDE_KEY => null, + ]) + ->getJson('/localization/context?plane=system') + ->assertSuccessful() + ->assertJsonPath('locale', 'en') + ->assertJsonPath('source', LocaleResolver::SOURCE_SYSTEM_DEFAULT) + ->assertJsonPath('user_preference_locale', null) + ->assertJsonPath('workspace_default_locale', null); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + LocaleResolver::SESSION_OVERRIDE_KEY => 'de', + ]) + ->getJson('/localization/context?plane=system') + ->assertSuccessful() + ->assertJsonPath('locale', 'de') + ->assertJsonPath('source', LocaleResolver::SOURCE_EXPLICIT_OVERRIDE); +}); diff --git a/apps/platform/tests/Feature/Localization/LocalePreferenceFlowTest.php b/apps/platform/tests/Feature/Localization/LocalePreferenceFlowTest.php new file mode 100644 index 00000000..1417cb83 --- /dev/null +++ b/apps/platform/tests/Feature/Localization/LocalePreferenceFlowTest.php @@ -0,0 +1,87 @@ +updateWorkspaceSetting( + actor: $user, + workspace: $workspace, + domain: LocaleResolver::SETTING_DOMAIN, + key: LocaleResolver::SETTING_DEFAULT_LOCALE, + value: 'de', + ); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->getJson('/localization/context') + ->assertSuccessful() + ->assertJsonPath('locale', 'de') + ->assertJsonPath('source', LocaleResolver::SOURCE_WORKSPACE_DEFAULT); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->post(route('localization.preference.update'), ['preferred_locale' => 'en']) + ->assertRedirect(); + + expect($user->refresh()->preferred_locale)->toBe('en'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->getJson('/localization/context') + ->assertSuccessful() + ->assertJsonPath('locale', 'en') + ->assertJsonPath('source', LocaleResolver::SOURCE_USER_PREFERENCE); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->post(route('localization.preference.update'), ['preferred_locale' => '']) + ->assertRedirect(); + + expect($user->refresh()->preferred_locale)->toBeNull(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->getJson('/localization/context') + ->assertSuccessful() + ->assertJsonPath('locale', 'de') + ->assertJsonPath('source', LocaleResolver::SOURCE_WORKSPACE_DEFAULT); +}); + +it('allows temporary overrides to win until cleared', function (): void { + [$workspace, $user] = localizationWorkspaceMember(); + + $user->forceFill(['preferred_locale' => 'en'])->save(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->post(route('localization.override.update'), ['locale' => 'de']) + ->assertRedirect(); + + expect(session(LocaleResolver::SESSION_OVERRIDE_KEY))->toBe('de'); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + LocaleResolver::SESSION_OVERRIDE_KEY => 'de', + ]) + ->getJson('/localization/context') + ->assertSuccessful() + ->assertJsonPath('locale', 'de') + ->assertJsonPath('source', LocaleResolver::SOURCE_EXPLICIT_OVERRIDE); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + LocaleResolver::SESSION_OVERRIDE_KEY => 'de', + ]) + ->delete(route('localization.override.clear')) + ->assertRedirect(); + + expect(session(LocaleResolver::SESSION_OVERRIDE_KEY))->toBeNull(); +}); diff --git a/apps/platform/tests/Feature/Localization/LocalizedNotificationFormattingTest.php b/apps/platform/tests/Feature/Localization/LocalizedNotificationFormattingTest.php new file mode 100644 index 00000000..00dd114c --- /dev/null +++ b/apps/platform/tests/Feature/Localization/LocalizedNotificationFormattingTest.php @@ -0,0 +1,47 @@ +actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + LocaleResolver::SESSION_OVERRIDE_KEY => 'de', + ]) + ->post(route('localization.preference.update'), ['preferred_locale' => 'de']) + ->assertRedirect() + ->assertSessionHas('status', 'Spracheinstellung gespeichert.'); +}); + +it('formats override feedback in the newly effective locale', function (): void { + [$workspace, $user] = localizationWorkspaceMember(); + + app(SettingsWriter::class)->updateWorkspaceSetting( + actor: $user, + workspace: $workspace, + domain: LocaleResolver::SETTING_DOMAIN, + key: LocaleResolver::SETTING_DEFAULT_LOCALE, + value: 'de', + ); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->post(route('localization.override.update'), ['locale' => 'de']) + ->assertRedirect() + ->assertSessionHas('status', 'Sprachüberschreibung angewendet.'); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + LocaleResolver::SESSION_OVERRIDE_KEY => 'en', + ]) + ->delete(route('localization.override.clear')) + ->assertRedirect() + ->assertSessionHas('status', 'Sprachüberschreibung gelöscht.'); +}); diff --git a/apps/platform/tests/Feature/Localization/MachineFormatInvarianceTest.php b/apps/platform/tests/Feature/Localization/MachineFormatInvarianceTest.php new file mode 100644 index 00000000..15a5cdc5 --- /dev/null +++ b/apps/platform/tests/Feature/Localization/MachineFormatInvarianceTest.php @@ -0,0 +1,31 @@ +updateWorkspaceSetting( + actor: $user, + workspace: $workspace, + domain: LocaleResolver::SETTING_DOMAIN, + key: LocaleResolver::SETTING_DEFAULT_LOCALE, + value: 'de', + ); + + $audit = AuditLog::query()->latest('id')->first(); + + expect($audit)->not->toBeNull() + ->and($audit->action)->toBe(AuditActionId::WorkspaceSettingUpdated->value) + ->and(data_get($audit->metadata, 'domain'))->toBe(LocaleResolver::SETTING_DOMAIN) + ->and(data_get($audit->metadata, 'key'))->toBe(LocaleResolver::SETTING_DEFAULT_LOCALE) + ->and(data_get($audit->metadata, 'after_value'))->toBe('de'); +}); diff --git a/apps/platform/tests/Feature/Localization/TranslationFallbackGuardTest.php b/apps/platform/tests/Feature/Localization/TranslationFallbackGuardTest.php new file mode 100644 index 00000000..58dc743c --- /dev/null +++ b/apps/platform/tests/Feature/Localization/TranslationFallbackGuardTest.php @@ -0,0 +1,23 @@ + 'English fallback probe'], 'en'); + + App::setFallbackLocale('en'); + App::setLocale('de'); + + expect(__('localization.fallback_probe'))->toBe('English fallback probe'); +}); + +it('does not expose raw translation keys for supported first-wave catalogs', function (): void { + App::setLocale('de'); + + expect(__('localization.auth.sign_in_microsoft'))->not->toBe('localization.auth.sign_in_microsoft') + ->and(__('baseline-compare.button_view_findings'))->not->toBe('baseline-compare.button_view_findings') + ->and(__('findings.rbac.restore_unsupported'))->not->toBe('findings.rbac.restore_unsupported'); +}); diff --git a/apps/platform/tests/Feature/Localization/WorkspaceDefaultLocaleTest.php b/apps/platform/tests/Feature/Localization/WorkspaceDefaultLocaleTest.php new file mode 100644 index 00000000..508572d6 --- /dev/null +++ b/apps/platform/tests/Feature/Localization/WorkspaceDefaultLocaleTest.php @@ -0,0 +1,50 @@ +actingAs($user) + ->get(WorkspaceSettings::getUrl(panel: 'admin')) + ->assertSuccessful(); + + Livewire::actingAs($user) + ->test(WorkspaceSettings::class) + ->assertSet('data.localization_default_locale', null) + ->set('data.localization_default_locale', 'de') + ->callAction('save') + ->assertHasNoErrors() + ->assertSet('data.localization_default_locale', 'de'); + + expect(app(SettingsResolver::class)->resolveValue($workspace, LocaleResolver::SETTING_DOMAIN, LocaleResolver::SETTING_DEFAULT_LOCALE)) + ->toBe('de'); + + expect(WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', LocaleResolver::SETTING_DOMAIN) + ->where('key', LocaleResolver::SETTING_DEFAULT_LOCALE) + ->exists())->toBeTrue(); +}); + +it('keeps workspace default locale authorization aligned to settings capabilities', function (): void { + [$workspace, $user] = localizationWorkspaceMember('readonly'); + + $this->actingAs($user) + ->get(WorkspaceSettings::getUrl(panel: 'admin')) + ->assertSuccessful(); + + Livewire::actingAs($user) + ->test(WorkspaceSettings::class) + ->assertSet('data.localization_default_locale', null) + ->assertActionVisible('save') + ->assertActionDisabled('save') + ->call('save') + ->assertStatus(403); +}); diff --git a/apps/platform/tests/Pest.php b/apps/platform/tests/Pest.php index c57f5b1d..326e9b21 100644 --- a/apps/platform/tests/Pest.php +++ b/apps/platform/tests/Pest.php @@ -164,6 +164,25 @@ function something() // .. } +/** + * @return array{0: Workspace, 1: User} + */ +function localizationWorkspaceMember(string $role = 'manager'): array +{ + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => $role, + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + return [$workspace, $user]; +} + function repo_root(): string { $configuredRoot = env('TENANTATLAS_REPO_ROOT'); diff --git a/apps/platform/tests/Unit/Localization/LocaleResolverTest.php b/apps/platform/tests/Unit/Localization/LocaleResolverTest.php new file mode 100644 index 00000000..91e75f3b --- /dev/null +++ b/apps/platform/tests/Unit/Localization/LocaleResolverTest.php @@ -0,0 +1,83 @@ +resolveFromSources('de', 'en', 'en', 'en')) + ->toMatchArray([ + 'locale' => 'de', + 'source' => LocaleResolver::SOURCE_EXPLICIT_OVERRIDE, + 'machine_artifacts_invariant' => true, + ]); + + expect($resolver->resolveFromSources(null, 'de', 'en', 'en')) + ->toMatchArray([ + 'locale' => 'de', + 'source' => LocaleResolver::SOURCE_USER_PREFERENCE, + ]); + + expect($resolver->resolveFromSources(null, null, 'de', 'en')) + ->toMatchArray([ + 'locale' => 'de', + 'source' => LocaleResolver::SOURCE_WORKSPACE_DEFAULT, + ]); + + expect($resolver->resolveFromSources(null, null, null, 'de')) + ->toMatchArray([ + 'locale' => 'de', + 'source' => LocaleResolver::SOURCE_SYSTEM_DEFAULT, + ]); +}); + +it('falls through unsupported locale sources safely', function (): void { + $resolver = unitLocaleResolver(); + + $context = $resolver->resolveFromSources('fr', 'es', 'de', 'en'); + + expect($context) + ->toMatchArray([ + 'locale' => 'de', + 'source' => LocaleResolver::SOURCE_WORKSPACE_DEFAULT, + 'fallback_locale' => 'en', + ]) + ->and($context['user_preference_locale'])->toBeNull(); +}); + +it('keeps system panel resolution to explicit override or system default only', function (): void { + $resolver = unitLocaleResolver(); + + expect($resolver->resolveFromSources( + explicitOverride: null, + userPreference: 'de', + workspaceDefault: 'de', + systemDefault: 'en', + includeUserPreference: false, + includeWorkspaceDefault: false, + ))->toMatchArray([ + 'locale' => 'en', + 'source' => LocaleResolver::SOURCE_SYSTEM_DEFAULT, + 'user_preference_locale' => null, + 'workspace_default_locale' => null, + ]); + + expect($resolver->resolveFromSources( + explicitOverride: 'de', + userPreference: 'en', + workspaceDefault: 'en', + systemDefault: 'en', + includeUserPreference: false, + includeWorkspaceDefault: false, + ))->toMatchArray([ + 'locale' => 'de', + 'source' => LocaleResolver::SOURCE_EXPLICIT_OVERRIDE, + ]); +}); diff --git a/docs/product/implementation-ledger.md b/docs/product/implementation-ledger.md index 5be9ca39..b5f12e4d 100644 --- a/docs/product/implementation-ledger.md +++ b/docs/product/implementation-ledger.md @@ -15,7 +15,7 @@ ## Purpose ## Current Product Position -TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls. Die Repo-Wahrheit liegt damit ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Review- und Portfolio-Plattform ausgereift: Customer-safe Review Consumption, Cross-Tenant-Workflows und kommerzielle Lifecycle-Reife sind noch unvollstaendig. +TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls. Die Repo-Wahrheit liegt damit ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Review- und Portfolio-Plattform ausgereift: Customer-safe Review Consumption, Cross-Tenant-Workflows und kommerzielle Lifecycle-Reife sind noch unvollstaendig. Zusaetzlich zeigt der Repo-Stand eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden. ## Status Model @@ -51,7 +51,7 @@ ## Roadmap Coverage Summary | Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. | | R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. | | R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Support und Help sind real; kundensichere Review-Consumption ist noch offen. | -| Findings Workflow v2 / Execution Layer | implemented_partial | strong | yes | repo tests, not run | almost | Triage, Ownership, Alerts und Hygiene sind vorhanden; der naechste Operator-Layer fehlt. | +| Findings Workflow v2 / Execution Layer | implemented_partial | strong | yes | repo tests, not run | almost | Triage, Ownership, Alerts und Hygiene sind vorhanden; der naechste Operator-Layer fehlt und Legacy-Cleanup um Backfill-/Status-Kompatibilitaet bleibt offen. | | Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. | | Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. | | Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. | @@ -106,7 +106,7 @@ ## Foundation-Only Capabilities ## Partial Capabilities - Customer-facing review consumption: Tenant Reviews, Evidence Snapshots und Review Packs sind stark, aber ein repo-verifizierter Customer Review Workspace fehlt. -- Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer. +- Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer; zusaetzlich bleibt Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten. - Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht. - MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen. - Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch. @@ -179,6 +179,9 @@ ## Open Gaps & Blockers |---|---|---|---|---| | Customer-safe review workspace is missing | Release blocker | Existing review and evidence assets cannot yet be consumed as a clear customer-facing surface | R2 completion / Tenant Reviews | P0 Customer Review Workspace v1 | | No consolidated operator decision inbox | UX blocker | Operators still move between findings, runs, alerts and portfolio surfaces to act | Findings Workflow / MSP Portfolio | P0 Decision-Based Governance Inbox v1 | +| Findings lifecycle backfill runtime surfaces remain productized | Cleanup blocker | Runbooks, commands, capabilities and tenant actions still expose a pre-production repair path that should not ship as product truth | Findings Workflow / Legacy Removal | P1 Remove Findings Lifecycle Backfill Runtime Surfaces | +| Legacy `acknowledged` status compatibility still survives | Semantics blocker | Status helpers, filters, badges, capability aliases and tests keep non-canonical workflow semantics alive | Findings Workflow / RBAC | P1 Remove Legacy Acknowledged Finding Status Compatibility | +| Creation-time finding invariants are implied but not explicitly protected | Integrity blocker | Future finding generators could regress into partial lifecycle writes and recreate the need for repair tooling | Findings Workflow / Data Integrity | P1 Enforce Creation-Time Finding Invariants | | Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 | | Localization foundation is absent | UX blocker | Product polish and DACH-readiness remain limited | R1.9 Platform Localization v1 | P1 Localization v1 | | Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity | @@ -191,6 +194,9 @@ ## Recommended Next Specs - `P0 Customer Review Workspace v1`: turns existing reviews, evidence and review-pack outputs into a customer-safe read-only product surface. - `P0 Decision-Based Governance Inbox v1`: consolidates existing findings, runs, alerts and triage signals into one operator work surface. +- `P1 Remove Findings Lifecycle Backfill Runtime Surfaces`: removes visible pre-production repair tooling from runbooks, commands, actions, capabilities and deploy/runtime hooks. +- `P1 Remove Legacy Acknowledged Finding Status Compatibility`: collapses findings workflow semantics onto the canonical `triaged` model and removes stale RBAC/query aliases. +- `P1 Enforce Creation-Time Finding Invariants`: proves that new findings are lifecycle-ready at write time so no repair backfill has to return later. - `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action. - `P1 Localization v1`: still absent in repo and becomes more expensive the later it lands. - `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle. diff --git a/docs/product/spec-candidates.md b/docs/product/spec-candidates.md index 4a2e0b33..a7fd663e 100644 --- a/docs/product/spec-candidates.md +++ b/docs/product/spec-candidates.md @@ -3,7 +3,7 @@ # Spec Candidates > Repo-based next-spec queue for TenantPilot. > This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs. -> **Last reviewed**: 2026-04-27 +> **Last reviewed**: 2026-04-28 > **Basis**: `implementation-ledger.md`, `roadmap.md`, current `specs/` truth --- @@ -138,6 +138,94 @@ ### Localization v1 - locale-aware formatting does not affect audit or export truth - targeted regression coverage exists for fallback and key critical flows +### Remove Findings Lifecycle Backfill Runtime Surfaces +- **Priority**: P1 +- **Why this stays active**: Repo audit shows visible runtime surfaces for a pre-production findings lifecycle repair path even though active finding generators already write the relevant lifecycle fields directly. The remaining path is not just ballast; it appears partially detached from current operational-control truth and keeps internal repair tooling productized. +- **Roadmap relationship**: Findings workflow cleanup / legacy removal. +- **Dependencies**: + - current finding generators that already set lifecycle fields directly + - system runbook registry and execution surfaces + - tenant findings actions + - operation catalog, capability, and seeder bindings + - backfill jobs, runbook service, and deploy hooks +- **Scope**: + - remove the system runbook `Rebuild Findings Lifecycle` + - remove the tenant action `Backfill findings lifecycle` + - remove the command `tenantpilot:findings:backfill-lifecycle` + - remove findings lifecycle backfill jobs, runbook services, and deploy/runtime hooks + - remove operation-catalog, capability, seeder, and test traces that exist only for this backfill path +- **Non-scope**: + - removing the legacy `acknowledged` status or related compatibility helpers + - changing normal finding workflow actions such as triage, assignment, progress, resolve, or risk acceptance + - changing ownership, assignee, SLA, due-date, or risk-governance semantics + - changing historical migrations or adding replacement backfills +- **Acceptance criteria**: + - no `/admin` surface exposes `Backfill findings lifecycle` + - no system runbook exposes `Rebuild Findings Lifecycle` + - `tenantpilot:findings:backfill-lifecycle` is no longer a supported command + - deploy or operational hooks do not start a findings lifecycle backfill + - `findings.lifecycle.backfill` is no longer used as an operational-control key, operation type, or capability + - tests no longer expect backfill preflight, start, or completion behavior + - normal finding workflows keep working unchanged for triage, assignment, start progress, resolve, and risk acceptance +- **Notes**: This is the first and most important cleanup candidate because it removes visible product ballast without changing the canonical findings workflow semantics. + +### Remove Legacy Acknowledged Finding Status Compatibility +- **Priority**: P1 +- **Why this stays active**: Repo audit indicates that `acknowledged` compatibility still survives in status helpers, filters, badges, capabilities, and tests even though the current operator workflow is centered on `triaged`. Keeping both semantics alive weakens workflow clarity and RBAC consistency. +- **Roadmap relationship**: Findings workflow semantics / RBAC cleanup. +- **Dependencies**: + - finding status constants and model helpers + - badge and filter catalogs + - role capability mappings and capability aliases + - workflow and bulk-action tests that still speak in acknowledge semantics +- **Scope**: + - remove `Finding::STATUS_ACKNOWLEDGED` + - remove or simplify compatibility helpers that only map `acknowledged` to `triaged` + - remove `openStatusesForQuery()` compatibility for `acknowledged` + - remove legacy capability aliases such as `tenant_findings.acknowledge` + - rename, adapt, or remove tests that only protect the old acknowledge vocabulary + - ensure active workflow actions consistently use `triage` / `triaged` +- **Non-scope**: + - removing findings lifecycle backfill runtime surfaces in the same slice + - changing SLA, ownership, assignee, or risk-acceptance behavior + - introducing new workflow states or new customer-facing workflow surfaces + - changing finding generators unless they still emit `acknowledged` +- **Acceptance criteria**: + - no productive code path writes `acknowledged` + - no productive code path expects `acknowledged` as a valid workflow status + - `tenant_findings.acknowledge` no longer exists as a capability or alias + - workflow actions, filters, badges, and tests consistently use `triage` / `triaged` + - existing finding flows remain functional from `new` to `triaged`, `in_progress`, `resolved`, and risk-accepted outcomes +- **Notes**: Keep this separate from backfill removal because it reaches deeper into workflow semantics, queries, badges, and RBAC mappings. + +### Enforce Creation-Time Finding Invariants +- **Priority**: P1 +- **Why this stays active**: Removing lifecycle backfills only stays safe if new findings are always created in a lifecycle-ready state. The repo already hints at good direct-write behavior, but those invariants still need explicit protection so future generators do not recreate the need for repair jobs. +- **Roadmap relationship**: Findings data integrity / workflow hardening. +- **Dependencies**: + - drift and baseline compare finding generation + - permission posture finding generation + - Entra admin roles finding generation + - rediscovery, reopen, and deduplication behavior around recurrence keys and lifecycle timestamps +- **Scope**: + - review active finding generators and verify lifecycle-ready creation + - add or tighten invariant tests around canonical status, first/last seen timestamps, `times_seen`, `sla_days`, and `due_at` where applicable + - verify reopen and rediscovery behavior + - verify drift idempotency and recurrence-key semantics + - consider a tightly bounded DB constraint only if the repo proves a safe, narrow case +- **Non-scope**: + - reintroducing any backfill or repair runtime surface + - historical data migration work + - forcing owner or assignee fields to become mandatory + - introducing new finding types or broader customer review workflow changes +- **Acceptance criteria**: + - repo-verified finding generators have tests that prove lifecycle-ready creation + - no new finding generation path relies on a later backfill or repair run + - repeated drift detection does not create uncontrolled canonical duplicates + - reopen or rediscovery behavior updates lifecycle fields correctly + - accountability remains a governance state rather than a forced owner/assignee requirement +- **Notes**: This should follow the visible cleanup work and protects the target state so findings do not regress back into repair-job dependency. + ### P2 — Commercial / Scale ### Commercial Entitlements and Billing-State Maturity diff --git a/specs/252-platform-localization-v1/checklists/requirements.md b/specs/252-platform-localization-v1/checklists/requirements.md new file mode 100644 index 00000000..3e071012 --- /dev/null +++ b/specs/252-platform-localization-v1/checklists/requirements.md @@ -0,0 +1,60 @@ +# Specification Quality Checklist: Platform Localization v1 (DE/EN) + +**Purpose**: Validate specification completeness and quality before proceeding to implementation planning +**Created**: 2026-04-28 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] Business value and operator outcomes stay explicit +- [x] Locale precedence, persistence ownership, and invariance boundaries are explicit +- [x] Runtime-governance sections are present for an implementation-ready spec package +- [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] Acceptance scenarios are defined for the primary user journeys +- [x] Edge cases are identified +- [x] Scope is clearly bounded to platform runtime localization, not website or broad documentation translation +- [x] Dependencies and assumptions are identified + +## Feature Readiness + +- [x] The first slice is small enough for a bounded implementation loop +- [x] The plan identifies the concrete repo surfaces likely to change +- [x] The tasks are ordered, testable, and grouped by user story +- [x] No unresolved product question blocks safe implementation of the first slice; system-panel scope is explicitly limited to explicit override plus system default in v1 + +## Governance Readiness + +- [x] New persistence is justified and remains minimal +- [x] Provider-boundary handling and glossary reuse are explicit +- [x] Existing RBAC and tenant/workspace isolation remain authoritative +- [x] Operator-facing surface changes include the required UI contract sections +- [x] Livewire v4 compliance, unchanged provider registration location, unchanged global-search semantics, no destructive-action additions, and unchanged asset strategy are explicit in the package +- [x] Export, audit, raw payload, and machine-readable invariance is explicit + +## UI / Surface Review Gate + +- [x] Applicability is explicit: this feature changes operator-facing shell, governance, monitoring, and customer-safe viewer surfaces, so a full review gate applies +- [x] Spec, plan, and tasks carry forward the same mixed native/custom classification, shared-family relevance, state-layer ownership, and no-current-exception posture +- [x] The slice stays native/shared-primitives first: one shared context bar, one workspace settings path, one locale resolver, and no second shell or page-local locale system +- [x] Repository signal handling is explicit as `review-mandatory`, with no current exception path or hidden parallel UX language +- [x] Required test-profile depth is explicit: `global-context-shell`, `standard-native-filament`, and `shared-detail-family`, with focused proof commands only +- [x] Audience-aware disclosure remains intact: localization changes decision-first UI copy, while support/raw payloads and machine-readable artifacts remain hidden or invariant + +## Review Outcome + +- [x] Review outcome class chosen: `acceptable-special-case` +- [x] Workflow outcome chosen: `keep` +- [x] Final note location is explicit: any implementation-era translation exceptions are recorded in the active feature close-out task `T022`; the prep package itself needs no current exception note + +## Notes + +- This checklist completes the implementation-ready package alongside `spec.md`, `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/`, and `tasks.md`. +- The active slice stays bounded to one locale foundation, two supported locales, one workspace-bound personal preference path, one workspace default path, system-panel explicit-override support only, and first-wave translation coverage for the most visible runtime surfaces. +- Current review outcome is `acceptable-special-case / keep` because the package is intentionally broad across surfaces but remains bounded to one shared locale foundation and one first-wave translation inventory. +- Implementation close-out on 2026-04-28 completed the targeted fast-feedback/confidence Pest lanes, dirty Pint, browser smoke, and post-implementation analysis/fix loop. Any remaining English text is documented as broader pre-existing localization debt outside the bounded first-wave slice, not as an open blocker for this spec. diff --git a/specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml b/specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml new file mode 100644 index 00000000..2c76a5c6 --- /dev/null +++ b/specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml @@ -0,0 +1,177 @@ +openapi: 3.1.0 +info: + title: Platform Localization Logical Contract + version: 0.1.0 + summary: Logical contract for locale resolution, preference persistence, and invariant machine-format behavior. +paths: + /localization/context: + get: + summary: Resolve the effective locale for the current request. + operationId: resolveLocalizationContext + description: Admin and tenant planes may resolve from explicit override, user preference, workspace default, or system default. The system plane resolves from explicit override or system default only in v1. + responses: + '200': + description: Effective locale context for the current request. + content: + application/json: + schema: + $ref: '#/components/schemas/ResolvedLocaleContext' + /localization/override: + put: + summary: Set or replace the explicit temporary locale override that sits first in the precedence chain. + operationId: updateExplicitLocaleOverride + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LocaleOverrideUpdate' + responses: + '200': + description: Updated locale context after setting the override. + content: + application/json: + schema: + $ref: '#/components/schemas/ResolvedLocaleContext' + '400': + description: Unsupported or malformed locale input was rejected and the request falls back safely. + delete: + summary: Clear the explicit temporary locale override and return to inherited behavior. + operationId: clearExplicitLocaleOverride + responses: + '204': + description: Explicit override cleared. + /users/me/locale-preference: + put: + summary: Persist the authenticated user's personal locale preference. + operationId: updateUserLocalePreference + description: Applies to the workspace-bound `User` actor on admin and tenant planes only. System-panel `PlatformUser` actors do not get a persisted locale preference in v1. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserLocalePreferenceUpdate' + responses: + '200': + description: Updated locale context after saving the preference. + content: + application/json: + schema: + $ref: '#/components/schemas/ResolvedLocaleContext' + '400': + description: Unsupported or malformed locale input was rejected and the request falls back safely. + '403': + description: Caller is authenticated but the current surface or policy does not allow personal locale preference mutation. + '404': + description: The personal preference path is unavailable in the current plane or membership context, including system-panel requests. + /workspaces/{workspaceId}/settings/localization/default-locale: + put: + summary: Persist the workspace-owned default locale through the existing settings surface. + operationId: updateWorkspaceDefaultLocale + description: Applies to workspace-scoped admin and tenant flows only. The system plane does not inherit workspace default in v1. + parameters: + - name: workspaceId + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WorkspaceDefaultLocaleUpdate' + responses: + '200': + description: Updated workspace default locale metadata. + content: + application/json: + schema: + $ref: '#/components/schemas/WorkspaceLocaleSetting' + '400': + description: Unsupported or malformed locale input was rejected. + '403': + description: Caller is a workspace member but lacks permission to manage workspace settings. + '404': + description: Workspace is inaccessible in the current plane or membership context. +components: + schemas: + SupportedLocale: + type: string + enum: + - en + - de + LocaleSource: + type: string + enum: + - explicit_override + - user_preference + - workspace_default + - system_default + ResolvedLocaleContext: + type: object + required: + - locale + - source + - fallback_locale + - machine_artifacts_invariant + properties: + locale: + $ref: '#/components/schemas/SupportedLocale' + source: + $ref: '#/components/schemas/LocaleSource' + fallback_locale: + type: string + const: en + user_preference_locale: + anyOf: + - $ref: '#/components/schemas/SupportedLocale' + - type: 'null' + workspace_default_locale: + anyOf: + - $ref: '#/components/schemas/SupportedLocale' + - type: 'null' + machine_artifacts_invariant: + type: boolean + const: true + UserLocalePreferenceUpdate: + type: object + required: + - preferred_locale + properties: + preferred_locale: + anyOf: + - $ref: '#/components/schemas/SupportedLocale' + - type: 'null' + description: Null clears the personal preference and returns the user to inherited behavior. + LocaleOverrideUpdate: + type: object + required: + - override_locale + properties: + override_locale: + $ref: '#/components/schemas/SupportedLocale' + description: Sets the explicit temporary override that takes precedence over persisted preference and workspace default. + WorkspaceDefaultLocaleUpdate: + type: object + required: + - default_locale + properties: + default_locale: + anyOf: + - $ref: '#/components/schemas/SupportedLocale' + - type: 'null' + description: Null returns the workspace to system-default inheritance. + WorkspaceLocaleSetting: + type: object + required: + - workspace_id + - default_locale + properties: + workspace_id: + type: integer + default_locale: + anyOf: + - $ref: '#/components/schemas/SupportedLocale' + - type: 'null' \ No newline at end of file diff --git a/specs/252-platform-localization-v1/data-model.md b/specs/252-platform-localization-v1/data-model.md new file mode 100644 index 00000000..ba9e73f2 --- /dev/null +++ b/specs/252-platform-localization-v1/data-model.md @@ -0,0 +1,65 @@ +# Data Model: Platform Localization v1 (DE/EN) + +## Supported Locale Set + +| Value | Meaning | Notes | +|---|---|---| +| `en` | English | System default and controlled fallback in v1 | +| `de` | German | First additional supported locale | + +## Locale Sources + +| Source | Ownership | Persistence | Allowed Values | Notes | +|---|---|---|---|---| +| `tenantpilot.locale_override` | request or session scoped | transient | `en`, `de` | Explicit temporary choice for the current browsing context | +| `users.preferred_locale` | user-owned | persisted on `users` | `en`, `de`, `null` | Personal preference; `null` means inherit | +| `localization.default_locale` | workspace-owned | existing workspace settings infrastructure | `en`, `de`, `null` | Workspace default for users without a personal preference | +| `config('app.locale')` | system-owned | config | `en` initially | Final fallback anchor | + +## Precedence Rule + +1. Explicit override +2. User preference +3. Workspace default +4. System default + +If the chosen source is missing, malformed, or unsupported, resolution falls back to the next valid source until a supported locale is found. The final controlled fallback is English. + +## Plane-Specific Resolution + +- **Admin and tenant panels**: use the full precedence rule above. +- **System panel**: uses `explicit override -> system default` only in v1 because system actors authenticate as `PlatformUser` and do not get a persisted locale preference or workspace-default inheritance in this slice. + +## Derived Resolved Locale Context + +| Field | Type | Meaning | +|---|---|---| +| `locale` | string | Effective locale for the current request (`en` or `de`) | +| `source` | string | One of `explicit_override`, `user_preference`, `workspace_default`, `system_default` | +| `fallback_locale` | string | Controlled fallback locale, `en` in v1 | +| `workspace_default_locale` | string or null | Current workspace default when a workspace context exists | +| `user_preference_locale` | string or null | Persisted personal locale preference for workspace-bound users; `null` on the system plane | + +## Persistence Shape + +- **User preference**: add one nullable locale preference field to the current workspace-bound user-owned surface. +- **Workspace default**: add one workspace setting definition under a localization-specific domain using the existing settings infrastructure. +- **No new table**: the first slice does not create a generic preferences or translation state table, and it does not add a second locale-preference store for `PlatformUser`. + +## Translation Catalog Ownership + +| Catalog Family | Ownership | Notes | +|---|---|---| +| `lang/en/*.php` | canonical English source | Existing `findings.php` and `baseline-compare.php` remain authoritative English catalogs | +| `lang/de/*.php` | German translation mirror | Added only for the selected first-wave surface families | +| generic shell or settings catalogs | platform runtime | Used for shell/auth/context-bar and shared operator text that does not belong to one domain file | + +## Invariance Boundaries + +The following stay non-localized in v1: + +- raw JSON and provider payloads +- audit entries and machine-readable audit values +- stored report payloads and exported artifact data +- identifiers, slugs, route parameters, and query semantics +- global-search scope, authorization outcomes, and tenant/workspace context selection diff --git a/specs/252-platform-localization-v1/plan.md b/specs/252-platform-localization-v1/plan.md new file mode 100644 index 00000000..e7844693 --- /dev/null +++ b/specs/252-platform-localization-v1/plan.md @@ -0,0 +1,287 @@ +# Implementation Plan: Platform Localization v1 (DE/EN) + +**Branch**: `252-platform-localization-v1` | **Date**: 2026-04-28 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +- Add one bounded locale foundation for the platform runtime only: admin and tenant panels use the full locale precedence chain (`explicit override -> user preference -> workspace default -> system default`), while the system panel uses the v1 subset (`explicit override -> system default`) because it authenticates a separate platform actor. +- Keep persistence narrow and repo-native: store the workspace default locale through the existing workspace settings infrastructure, persist the personal locale preference directly on the workspace-bound user surface, and avoid a generic preferences framework, a second settings stack, or a second preference store for `PlatformUser`. +- Translate the panel shell and the highest-signal governance surfaces first, including the shared context bar, auth copy, Findings, Baseline Compare, representative workspace and tenant membership tables, monitoring and operations feedback, and customer-safe review or report viewer chrome, while keeping exports, audit logs, JSON payloads, and other machine-readable artifacts invariant. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Filament v5 + Livewire v4, Laravel translator, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), current panel providers, existing Filament notifications and view layer +**Storage**: PostgreSQL via one workspace-bound user-owned locale preference field plus one workspace-owned locale default setting; translation catalogs in `apps/platform/lang/en` and `apps/platform/lang/de` +**Testing**: Pest unit and feature tests via Laravel Sail +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Monorepo Laravel web application in `apps/platform` with admin, tenant, and system Filament panels +**Project Type**: web +**Performance Goals**: No extra remote calls during locale resolution, constant-time locale lookup from request/session + current user + workspace settings, and no measurable overhead on ordinary panel navigation or Livewire round-trips +**Constraints**: Exactly two locales (`en`, `de`), no `apps/website` scope, no new global-search semantics, no RBAC behavior change, invariant CSV/JSON/audit/raw payloads, and no generic preference framework +**Scale/Scope**: One locale resolver, one request-time locale application seam, one workspace default setting, one workspace-bound personal preference path, system-panel explicit-override support only, and first-wave translation coverage for shell/auth plus core governance surface families + +## Filament v5 / Panel Notes + +- **Livewire v4.0+ compliance**: The slice stays inside existing Filament v5 pages, widgets, resources, render hooks, and Livewire-backed request flows. No Livewire v3 assumptions or compatibility work are introduced. +- **Provider registration location**: No panel/provider registration changes are planned. Existing Laravel 12 + Filament provider registration remains in `bootstrap/providers.php`. +- **Global search**: No new global-search resource is introduced and no global-search routing or authorization semantics are changed. Localization only affects visible copy where current search access already exists. +- **Destructive and high-impact actions**: No destructive action is added by this slice. Locale preference and workspace default changes are low-risk settings mutations; they still use existing authorization and settings audit paths where applicable. +- **Asset strategy**: No new panel or shared assets are planned. Deployment remains unchanged, including `cd apps/platform && php artisan filament:assets` when registered Filament assets are deployed elsewhere in the product. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: mixed +- **Shared-family relevance**: shell navigation, auth copy, workspace settings, notifications, status messaging, dashboard and compare surfaces, customer-safe report viewers +- **State layers in scope**: shell, page, detail, URL-query or session +- **Audience modes in scope**: operator-MSP, support-platform, customer-read-only +- **Decision/diagnostic/raw hierarchy plan**: decision-first on shell and core governance surfaces; diagnostics-second on monitoring and operational feedback surfaces; support/raw payloads stay third and unchanged +- **Raw/support gating plan**: unchanged capability-gated or collapsed raw detail; localization applies only to surrounding UI copy, not to raw payloads or audit artifacts +- **One-primary-action / duplicate-truth control**: the shell remains the one place where language is chosen intentionally; all other surfaces consume the resolved locale and do not become independent configuration surfaces +- **Handling modes by drift class or surface**: review-mandatory because mixed-language drift across shell, notifications, and core governance surfaces would undercut the shared locale contract immediately +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: global-context-shell, standard-native-filament, shared-detail-family +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: no page-local locale state, no custom translation framework, no second shell, and no localized machine artifacts +- **Active feature PR close-out entry**: Guardrail + +## Review Outcome + +- **Outcome class**: acceptable-special-case +- **Workflow outcome**: keep +- **Why this remains acceptable**: the package touches multiple surface families, but every change is still anchored to one shared locale contract and a tightly bounded first-wave translation inventory. + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `bootstrap/app.php`, panel providers (`AdminPanelProvider`, `TenantPanelProvider`, `SystemPanelProvider`), shared topbar render hook and `resources/views/filament/partials/context-bar.blade.php`, existing auth/login pages, workspace settings infrastructure, `User` model persistence, `PlatformUser`-backed system auth behavior, translation catalogs under `lang/`, Filament notifications, and representative governance/detail pages and report viewers +- **Shared abstractions reused**: existing translation helpers (`__()` and Laravel translator), existing settings registry/resolver/writer, current workspace context resolution, current panel render hooks, and existing Filament notification and page/resource surfaces +- **New abstraction introduced? why?**: one bounded `LocaleResolver` plus one request-time application seam are justified because the repo currently lacks any single locale precedence decision that can serve shell, auth, Livewire, notifications, and report viewers consistently +- **Why the existing abstraction was sufficient or insufficient**: Laravel translation helpers are already sufficient for rendering translated strings, and the workspace settings infrastructure is already sufficient for a workspace default on admin and tenant planes. They are insufficient because there is no central locale resolution contract and no workspace-bound user locale preference path today. +- **Bounded deviation / spread control**: no generic preferences registry, no page-local language switches, and no second translation catalog scheme beyond standard Laravel `lang/{locale}` files + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no +- **Central contract reused**: N/A +- **Delegated UX behaviors**: N/A +- **Surface-owned behavior kept local**: localized copy only on existing run and monitoring surfaces +- **Queued DB-notification policy**: N/A +- **Terminal notification path**: N/A +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: N/A +- **Platform-core seams**: locale resolution, glossary translation, UI copy, and viewer chrome language behavior +- **Neutral platform terms / contracts preserved**: `Finding`, `Baseline`, `Drift`, `Risk Accepted`, `Evidence Gap`, `Run`, `Alert`, `Workspace`, `Tenant` +- **Retained provider-specific semantics and why**: none; provider payloads remain untranslated and raw where they already exist +- **Bounded extraction or follow-up path**: none + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: PASS - the slice changes operator-facing runtime copy and locale choice only; it does not introduce new inventory or backup truth. +- Read/write separation: PASS - the only new writes are low-risk preference mutations using existing user/workspace ownership and current settings patterns. +- Graph contract path: PASS - no new Microsoft Graph path is introduced. +- Deterministic capabilities: PASS - authorization semantics for shell, workspace settings, and read-only viewers remain unchanged. +- RBAC-UX: PASS - `/admin`, `/admin/t`, and `/system` remain separated; language choice does not alter 404 versus 403 semantics. +- Workspace isolation: PASS - workspace selection and tenant selection stay authoritative, and locale does not create a second context layer. +- RBAC-UX destructive confirmation: N/A - no destructive action is introduced. +- RBAC-UX global search: PASS - search scope and visibility remain unchanged. +- Tenant isolation: PASS - translated labels and fallback text must not leak inaccessible tenant or workspace information. +- Run observability: PASS - no new run family or start flow is introduced. +- OperationRun start UX: N/A - no start semantics change. +- Ops-UX 3-surface feedback: PASS - only existing copy becomes locale-aware; lifecycle and notification mechanics stay unchanged. +- Ops-UX lifecycle: N/A - no lifecycle contract change. +- Ops-UX summary counts: N/A - no summary shape change. +- Ops-UX guards: N/A - no new run guard family is planned. +- Ops-UX system runs: N/A - unchanged. +- Automation: N/A - no new queued or scheduled workflow family is introduced. +- Data minimization: PASS - no new sensitive payload storage is introduced. +- Test governance (TEST-GOV-001): PASS - the plan stays in focused unit + feature lanes with explicit proof commands and limited fixture growth. +- Proportionality (PROP-001): PASS - persistence stays to one workspace-bound user-owned preference and one workspace setting; one resolver is the narrowest viable shared seam. +- No premature abstraction (ABSTR-001): PASS - no registry, strategy system, or framework is planned beyond one locale resolver and supported-locale allowlist. +- Persisted truth (PERSIST-001): PASS - the new persisted values represent real workspace-bound user and workspace-owned preference truth, while the system plane remains explicit-override or system-default only. +- Behavioral state (STATE-001): PASS - the locale set changes real request behavior, formatting, and translated surface output. +- UI semantics (UI-SEM-001): PASS - the plan favors direct domain-to-translation mapping instead of a new interpretation framework. +- Shared pattern first (XCUT-001): PASS - existing translator, panel hooks, settings stack, and existing page/resource surfaces are reused first. +- Provider boundary (PROV-001): PASS - localization is platform-core and provider-neutral. +- V1 explicitness / few layers (V1-EXP-001, LAYER-001): PASS - one resolver, one middleware path, two locales, and a bounded first-wave surface inventory. +- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): PASS - the whole locale foundation remains in one coherent spec and explicitly avoids website/email/framework drift. +- Badge semantics (BADGE-001): PASS - translated badges continue to use existing central semantics rather than new color or state mappings. +- Filament-native UI (UI-FIL-001): PASS - the slice extends existing Filament pages/resources/widgets, login pages, and render-hook partials. +- Filament-native UI local Blade/Tailwind: PASS - existing custom Blade surfaces like the shared context bar and selected viewer shells remain in Filament visual language. +- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): PASS - no new surface type is introduced. +- Decision-first operating model (DECIDE-001): PASS - shell choice happens once, and primary governance surfaces stay decision-first. +- Audience-aware disclosure (DECIDE-AUD-001 / OPSURF-001): PASS - localization improves readability without exposing hidden diagnostics or translating raw payloads. +- UI/UX inspect model (UI-HARD-001): PASS - no inspect/open model changes are planned. +- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): PASS - existing action hierarchy remains intact. +- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): PASS - canonical nouns remain stable across translated shells and pages. +- UI/UX placeholder ban (UI-HARD-001): PASS - no placeholder controls are planned. +- UI naming (UI-NAMING-001): PASS - translated labels preserve `Verb + Object` semantics and canonical domain vocabulary. +- Operator surfaces (OPSURF-001): PASS - shell, governance, monitoring, and customer-safe viewers stay explicit and bounded. +- Operator surface page contract: PASS - the spec already defines the affected surface contracts. +- Filament UI Action Surface Contract: PASS - no new action family is introduced beyond one shell-level locale control and a workspace settings field. +- Filament UI UX-001 (Layout & IA): PASS - the slice extends existing shells, settings, and detail surfaces only. +- Action-surface discipline (ACTSURF-001 / HDR-001): PASS - language choice stays on the shell or settings surfaces and is not duplicated on every page. +- UI review workflow: PASS - guardrail classification, shell ownership, fallback behavior, and invariant machine-format rules remain explicit in this plan. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Unit` for locale precedence and validation; `Feature` for request-time application, workspace and personal preference flows, translated core surfaces, localized feedback, and invariant machine-format behavior +- **Affected validation lanes**: `fast-feedback`, `confidence` +- **Why this lane mix is the narrowest sufficient proof**: the core risk is deterministic resolution and rendered surface behavior across existing request paths, not browser-only interaction nuance or heavy governance semantics +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Localization/LocaleResolverTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php tests/Feature/Localization/LocalePreferenceFlowTest.php tests/Feature/Localization/WorkspaceDefaultLocaleTest.php tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/LocalizedNotificationFormattingTest.php tests/Feature/Localization/TranslationFallbackGuardTest.php tests/Feature/Localization/MachineFormatInvarianceTest.php` +- **Fixture / helper / factory / seed / context cost risks**: limited to users, workspaces, memberships, workspace settings, session override state, and representative governance surface fixtures; add one focused wrong-plane or non-member and missing-capability proof path without widening the test family +- **Expensive defaults or shared helper growth introduced?**: no - implementation should reuse existing factories and add only thin locale helpers where repeated assertions demand it +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: global-context-shell proof for request-wide locale behavior, standard-native relief for ordinary Filament surfaces, and shared-detail-family proof for localized report viewer chrome with invariant artifacts +- **Closing validation and reviewer handoff**: rerun the exact targeted commands above, verify admin login, tenant panel, and system panel locale continuity, verify unsupported locale fallback behavior, verify dashboard plus core governance surfaces do not render raw keys, verify wrong-plane or non-member 404 and member-but-no-capability 403 behavior stays unchanged under locale changes, and verify exported or audited machine formats remain stable with no new remote locale lookups introduced +- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local growth +- **Review-stop questions**: does one resolver truly own locale precedence, does Livewire preserve the selected locale, does the first-wave translation scope stay bounded, and do exports or audit payloads remain invariant +- **Escalation path**: document-in-feature if one surface family needs temporary English-only fallback; follow-up-spec only if a later broader email or website localization program becomes necessary +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: the planned work stays bounded to the platform runtime and current high-signal governance surfaces; broader public-site or multi-locale expansion remains explicitly out of scope + +## Project Structure + +### Documentation (this feature) + +```text +specs/252-platform-localization-v1/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── locale-resolution-and-translation-governance.logical.openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Http/ +│ │ └── Middleware/ +│ │ └── ApplyResolvedLocale.php +│ ├── Models/ +│ │ └── User.php +│ ├── Providers/ +│ │ ├── AppServiceProvider.php +│ │ └── Filament/ +│ │ ├── AdminPanelProvider.php +│ │ ├── TenantPanelProvider.php +│ │ └── SystemPanelProvider.php +│ ├── Services/ +│ │ ├── Localization/ +│ │ │ └── LocaleResolver.php +│ │ └── Settings/ +│ │ ├── SettingsResolver.php +│ │ └── SettingsWriter.php +│ └── Support/ +│ └── Settings/ +│ └── SettingsRegistry.php +├── bootstrap/ +│ └── app.php +├── database/ +│ └── migrations/ +│ └── *_add_preferred_locale_to_users_table.php +├── lang/ +│ ├── de/ +│ └── en/ +├── resources/views/ +│ └── filament/ +│ ├── partials/context-bar.blade.php +│ ├── pages/ +│ ├── widgets/ +│ └── system/ +└── tests/ + ├── Feature/ + │ ├── Filament/Localization/ + │ └── Localization/ + └── Unit/ + └── Localization/ +``` + +**Structure Decision**: Single Laravel/Filament application inside `apps/platform`, with one new bounded localization resolver, one request-time locale application path, one workspace-bound user preference mutation path, one workspace-owned default setting, system-panel explicit-override support only, and focused translation catalog growth for selected existing surfaces. + +## Likely Implementation Surfaces + +- `bootstrap/app.php` plus a new `app/Http/Middleware/ApplyResolvedLocale.php` for request-time locale application in ordinary web requests and panel traffic +- `app/Providers/Filament/AdminPanelProvider.php`, `TenantPanelProvider.php`, and `SystemPanelProvider.php` for consistent panel-level middleware and shell affordances +- `resources/views/filament/partials/context-bar.blade.php` as the shared topbar language-control anchor for the admin and tenant panels +- current auth pages such as `app/Filament/Pages/Auth/Login.php` and `app/Filament/System/Pages/Auth/Login.php` for translated login and auth-adjacent copy +- `app/Models/User.php` plus a user migration for the personal locale preference field, while `PlatformUser` remains on explicit override plus system default only +- `app/Support/Settings/SettingsRegistry.php`, `app/Services/Settings/SettingsResolver.php`, `app/Services/Settings/SettingsWriter.php`, and `app/Filament/Pages/Settings/WorkspaceSettings.php` for the workspace-owned default locale path and audit-backed save semantics +- a new `app/Services/Localization/LocaleResolver.php` for precedence, supported-locale validation, and fallback behavior +- `lang/en/*` and new `lang/de/*` catalogs for shell, Findings, Baseline Compare, monitoring, operations, workspace or tenant management tables, and customer-safe review or report viewer shells +- representative existing surfaces such as `app/Filament/Pages/TenantDashboard.php`, `app/Filament/System/Pages/Dashboard.php`, `app/Filament/Resources/FindingResource.php`, `app/Filament/Pages/BaselineCompareLanding.php`, `resources/views/filament/pages/baseline-compare-landing.blade.php`, `resources/views/filament/widgets/tenant/tenant-review-pack-card.blade.php`, `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`, and `app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php` +- localized feedback surfaces such as current Filament notifications, validation messages, and relative-time labels already present across monitoring, onboarding, and review surfaces + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| One bounded `LocaleResolver` | Shell, auth, Livewire, notifications, and report viewers need one deterministic locale source | Page-local or panel-local locale reads would drift immediately and make fallback behavior inconsistent | +| One new workspace locale setting plus one personal preference field | The roadmap precedence chain requires real persisted workspace and user truth | Session-only locale switching would not satisfy inherited defaults or stable user choice | + +## Proportionality Review + +- **Current operator problem**: The product is partially translation-aware but not intentionally localized. Operators cannot choose a language reliably, and current core surfaces mix raw English with extracted translations. +- **Existing structure is insufficient because**: Laravel translation helpers alone do not answer which locale to use, when to inherit workspace defaults, how to persist a user choice, or how to keep Livewire and report-viewer surfaces aligned. +- **Narrowest correct implementation**: exactly two locales, one locale resolver where admin/tenant panels use the full precedence chain and the system panel uses explicit override plus system default, one workspace-bound user preference field, one workspace setting, one request-time application path, and a bounded first-wave translation inventory on existing high-signal surfaces. +- **Ownership cost created**: ongoing EN/DE catalog maintenance, one resolver, one migration, one workspace setting key, and regression tests for fallback plus invariant machine formats. +- **Alternative intentionally rejected**: a generic preferences framework, broad website/email program, or translating every page first was rejected because the current release needs a runtime foundation, not a full localization platform. +- **Release truth**: current-release truth. Core governance, monitoring, and customer-safe review surfaces already need language continuity in the live platform. + +## Phase 0 — Research (output: `research.md`) + +See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/research.md` + +Goals: +- Confirm the narrowest persistence shape for user preference plus workspace default without creating a generic preferences subsystem. +- Confirm the cleanest request-time locale application seam across normal web and Livewire requests for all three current panels, while keeping the system panel on explicit override plus system default only. +- Confirm which first-wave governance and viewer surfaces are already translation-aware enough to translate now and which ones still rely on raw English strings. +- Confirm invariant machine-format boundaries for exports, audit entries, report payloads, and raw evidence. + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +See: +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/data-model.md` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md` + +Design focus: +- Persist one personal locale preference directly on the workspace-bound user-owned surface and one workspace default locale through the existing settings infrastructure. +- Add one bounded locale resolver plus one request-time middleware or application path shared by admin, tenant, and system panels, with explicit override plus system default only on the system plane. +- Place the user-facing locale switch on the existing shared shell or context surface instead of inventing a new page shell. +- Translate first-wave shell, governance, monitoring, and customer-safe viewer surfaces using standard Laravel catalogs and controlled English fallback. +- Keep exports, audit logs, raw JSON, and machine-readable artifacts invariant even when the surrounding UI becomes locale-aware. + +## Implementation Close-Out + +- **Workflow outcome**: keep. +- **Validation lanes completed**: fast-feedback and confidence. +- **Targeted proof results**: + - `./vendor/bin/sail artisan test --compact tests/Unit/Localization/LocaleResolverTest.php ... tests/Feature/Localization/MachineFormatInvarianceTest.php` passed with 15 tests and 103 assertions. + - `./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsAuditTest.php tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php` passed with 18 tests and 248 assertions. + - `./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php` passed with 24 tests and 135 assertions. + - `./vendor/bin/sail bin pint --dirty --format agent` passed. + - `git diff --check` passed. +- **Browser smoke result**: passed on `http://localhost/admin/settings/workspace`, `http://localhost/admin/t/18000000-0000-4000-8000-000000000180`, and `http://localhost/admin/reviews/workspace`. The smoke verified the shared language switch from English to German, German locale menu state, tenant dashboard German navigation/title, customer review workspace German viewer chrome, no raw `localization.*` keys, and no current browser console errors from the tested tab. +- **Guardrail close-out**: acceptable-special-case / keep remains valid because the implementation still uses one resolver, one middleware seam, one user preference field, one workspace setting key, standard Laravel catalogs, and no localized machine artifact path. +- **document-in-feature note**: broader pre-existing Workspace Settings sections and deeper diagnostic/payload text outside the locale setting and review/report chrome may still render English in German mode. This is recorded as existing unrelated localization debt rather than widened into this first platform-runtime slice; the active implementation localizes the new locale controls, workspace default locale field, core shell/dashboard labels, Findings/Baseline catalog coverage, notifications, and customer-safe review/report chrome. diff --git a/specs/252-platform-localization-v1/quickstart.md b/specs/252-platform-localization-v1/quickstart.md new file mode 100644 index 00000000..2b2995f2 --- /dev/null +++ b/specs/252-platform-localization-v1/quickstart.md @@ -0,0 +1,39 @@ +# Quickstart: Platform Localization v1 (DE/EN) + +## Goal + +Implement one deterministic locale foundation for the platform runtime, then translate the first-wave shell and governance surfaces without changing authorization or machine-readable artifact truth. + +## Targeted Validation Commands + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Localization/LocaleResolverTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php tests/Feature/Localization/LocalePreferenceFlowTest.php tests/Feature/Localization/WorkspaceDefaultLocaleTest.php tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/LocalizedNotificationFormattingTest.php tests/Feature/Localization/TranslationFallbackGuardTest.php tests/Feature/Localization/MachineFormatInvarianceTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +## Manual Smoke Focus + +1. Open the admin login page and a representative system-panel page, then verify locale-specific auth and system copy using explicit override plus system default only. +2. Set workspace default locale to `de` on the existing workspace settings surface, verify an inheriting user sees German admin shell, tenant shell, and tenant dashboard copy, then clear the workspace default and verify inheritance falls back to the system default. +3. Set a personal locale preference to `en` and verify the shell, dashboards, and representative governance pages switch back to English. +4. Apply and clear the explicit temporary override and verify it wins only while active. +5. Open representative Findings, Baseline Compare, and representative workspace or tenant management tables, then confirm headings, actions, empty states, and glossary terms follow the resolved locale. +6. Open representative monitoring, alert, and operations surfaces and confirm labels, notifications, and relative-time text follow the resolved locale without changing workflow semantics. +7. Open a customer-safe review or report viewer and confirm the viewer shell localizes while underlying artifact content and identifiers stay unchanged. +8. Trigger a representative validation error and a representative notification and confirm they render in the resolved locale. +9. Verify wrong-plane or non-member requests still resolve as 404 and member-but-no-capability requests still resolve as 403 after locale changes or overrides. +10. Verify exported or audited machine-readable values stay stable and non-localized. + +## Reviewer Watchpoints + +- One resolver owns locale precedence. +- The system panel is explicit-override plus system-default only in v1; it does not silently inherit workspace default or a second persisted preference model. +- Livewire requests preserve the already-resolved locale. +- Unsupported locale input falls back safely to English. +- Locale changes do not alter wrong-plane 404, non-member 404, member-but-no-capability 403, or global-search visibility. +- First-wave translation coverage stays bounded to the planned surface families. +- No raw translation keys appear on in-scope surfaces. +- Exports, audit entries, raw payloads, and IDs remain invariant. +- Locale resolution stays local to request, session, user, and workspace settings inputs with no extra remote lookups. diff --git a/specs/252-platform-localization-v1/research.md b/specs/252-platform-localization-v1/research.md new file mode 100644 index 00000000..715baa25 --- /dev/null +++ b/specs/252-platform-localization-v1/research.md @@ -0,0 +1,51 @@ +# Research: Platform Localization v1 (DE/EN) + +## Decision 1: Keep v1 to exactly two locales + +- **Decision**: Support exactly `en` and `de` in the initial slice. +- **Why**: The roadmap names DE/EN explicitly, the repo already defaults to English, and the main risk is establishing a trustworthy locale chain and translation ownership, not proving a broad language framework. +- **Rejected alternative**: A generic multi-locale system or plugin registry was rejected because it would import framework-level complexity before the first runtime locale foundation exists. + +## Decision 2: Persist one user preference plus one workspace default + +- **Decision**: Store the personal locale preference directly on the workspace-bound user-owned surface and store the workspace default locale through the existing workspace settings infrastructure. +- **Why**: The repo already has a strong workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`) but no generic user settings registry. A direct workspace-bound user preference field is the narrowest truthful shape. +- **Rejected alternative**: A new generic preferences table or user-settings registry was rejected because the first release needs only one personal locale field. + +## Decision 3: Resolve locale in one shared request-time seam + +- **Decision**: Add one `LocaleResolver` and one request-time application path that can run for normal web requests and Livewire requests across admin, tenant, and system panels. +- **Why**: Repo evidence shows three active panel providers, shared topbar partials, and existing Livewire-specific middleware. Locale must stay coherent across those paths. +- **Rejected alternative**: Per-panel or per-page locale logic was rejected because it would drift immediately and would not solve mixed-language notifications or viewer shells. + +## Decision 4: Keep system-panel locale scope narrower than admin or tenant scope + +- **Decision**: Admin and tenant panels use the full precedence chain, but the system panel uses `explicit override -> system default` only in v1. +- **Why**: Repo truth shows the system panel authenticates `PlatformUser`, which is a separate actor model from workspace-bound `User`. Adding a second persisted preference store for platform actors would widen the slice beyond the narrow runtime localization foundation. +- **Rejected alternative**: A new platform-user locale preference or implicit inheritance from workspace default was rejected because the system plane is not workspace-owned and should not silently reuse workspace preference semantics. + +## Decision 5: Reuse current shell and settings surfaces instead of inventing new UI + +- **Decision**: Use the shared context bar or existing shell-adjacent controls for the user-facing locale switch, and use the existing workspace settings page for the workspace default locale. +- **Why**: The repo already has a shared render-hook surface in `resources/views/filament/partials/context-bar.blade.php` and an existing `WorkspaceSettings` page. That is the narrowest native Filament path. +- **Rejected alternative**: A dedicated localization page or a second profile/settings shell was rejected because it would duplicate shell-level context choice. + +## Decision 6: Translate first-wave high-signal surfaces only + +- **Decision**: First-wave translation coverage includes shell/auth, the current dashboards, Findings, Baseline Compare, representative workspace and tenant management tables, monitoring or operational feedback labels, and customer-safe review or report viewer chrome. +- **Why**: Repo evidence already shows translation-related usage on Findings and Baseline Compare, while the shared context bar, dashboards, and several relationship tables still contain many raw English strings. These surfaces represent the most visible operator and customer-safe workflows. +- **Rejected alternative**: Translating every current page in one slice was rejected because it would broaden scope faster than the locale foundation can be validated. + +## Decision 7: Keep machine-readable artifacts invariant + +- **Decision**: Locale affects UI copy, validation text, and date or relative-time formatting on in-scope surfaces, but not raw JSON, CSV, audit entries, IDs, or stored machine-readable report artifacts. +- **Why**: The roadmap requires stable export and audit semantics, and the product already uses customer-safe viewers and operational evidence where raw truth must remain stable. +- **Rejected alternative**: Localizing stored or exported artifacts was rejected because it would blur audit truth and increase downstream compatibility cost. + +## Repo Evidence Snapshot + +- `config/app.php` currently uses English as both default and fallback locale. +- The repo currently has only two explicit language catalogs: `lang/en/findings.php` and `lang/en/baseline-compare.php`. +- Translation helpers (`__()`) are already used across multiple Filament resources, notifications, and Blade views, but many shell and management strings remain raw English. +- The shared context bar partial is a concrete shell anchor and currently contains multiple hard-coded English labels. +- The repo has no current locale resolver, no workspace locale setting, and no personal locale preference field on `User`. diff --git a/specs/252-platform-localization-v1/spec.md b/specs/252-platform-localization-v1/spec.md new file mode 100644 index 00000000..3e1e32a1 --- /dev/null +++ b/specs/252-platform-localization-v1/spec.md @@ -0,0 +1,319 @@ +# Feature Specification: Platform Localization v1 (DE/EN) + +**Feature Branch**: `252-platform-localization-v1` +**Created**: 2026-04-28 +**Status**: Draft +**Input**: User description: "Prepare the Spec Kit feature for Localization v1 as a narrow repo-grounded slice that introduces locale resolution and EN/DE translation coverage on core governance surfaces, reuses existing translation helpers and current admin/system panels, and stops before website localization or full documentation translation." + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: TenantPilot already contains scattered translation-aware code such as `__()` calls and two domain language files, but it still lacks one central locale resolution path, one supported locale policy, and one bounded definition of which operator-facing surfaces are translated first. +- **Today's failure**: Users cannot intentionally switch the platform language, many core surfaces still mix extracted translations with raw English strings, relative-time labels stay English-only, and customer-safe review/report flows cannot reliably align to the reader's language without risking raw keys or inconsistent terminology. +- **User-visible improvement**: Operators can use the product in English or German, new users inherit a workspace default language unless they set their own preference, core governance surfaces render consistent translated copy and locale-aware time labels, and exports, audit records, and machine-readable artifacts remain stable and non-localized. +- **Smallest enterprise-capable version**: Add one request-time locale foundation where admin and tenant panels use `explicit override -> user preference -> workspace default -> system default`, the system panel uses `explicit override -> system default`, support exactly `en` and `de`, persist only the workspace default plus personal user preference for workspace-bound users, translate the panel shell plus the highest-signal governance surfaces first, and enforce controlled English fallback with no raw translation keys in the UI. +- **Explicit non-goals**: No `apps/website` localization, no arbitrary locale/plugin system, no public docs translation pipeline, no CSV/JSON/audit artifact localization, no provider/API payload translation, no full outbound email/template program, and no search/sort behavior rewrite beyond verifying locale safety on the current core lists. +- **Permanent complexity imported**: One bounded locale precedence chain, one supported-locale allowlist, one workspace-owned default locale setting, one workspace-bound user-owned locale preference, additional `lang/en` and `lang/de` catalogs for the selected core surfaces, and focused regression tests for fallback, formatting, and invariant machine-readable outputs. +- **Why now**: `R1.9 Platform Localization v1 (DE/EN)` is explicitly unspecced in the roadmap, the repo already has partial translation scaffolding (`lang/en/findings.php`, `lang/en/baseline-compare.php`, many `__()` calls), and read-only/customer-safe review maturity now needs a trustworthy locale foundation before more outward-facing product work lands. +- **Why not local**: Locale choice must affect the same request across Filament shells, auth, Livewire requests, notifications, relative-time rendering, and core governance pages. Translating page by page without a shared resolver contract would hard-code inconsistent language sources immediately. +- **Approval class**: Core Enterprise +- **Red flags triggered**: Foundation-sounding theme, cross-surface touchpoint, and one new shared resolver. Defense: the slice is strictly limited to two locales, one precedence chain, one workspace default, one personal preference, and a bounded first-wave translation set on already-real surfaces. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Review Outcome + +- **Outcome class**: acceptable-special-case +- **Workflow outcome**: keep +- **Reason**: The slice is intentionally broad across visible runtime surfaces, but it stays bounded to one shared locale foundation, two supported locales, one user preference path, one workspace default path, and first-wave translation coverage only. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view +- **Primary Routes**: + - current `/admin` and `/system` panel shells, including auth entry surfaces such as `/admin/login` + - the existing workspace settings surface for a workspace-owned default locale + - the current self-service user locale preference entry point in the panel shell or profile/user-menu area + - existing high-signal governance surfaces under `/admin`, including dashboard, Findings, Baseline Compare, Alerts/Monitoring, Operations, and customer-safe review/report consumption surfaces +- **Data Ownership**: Personal locale preference is user-owned truth for the workspace-bound `User` actor and should persist on that user surface only. Workspace default locale is workspace-owned truth and should reuse the existing workspace settings infrastructure for admin/tenant-plane inheritance only. Explicit override is transient request/session state. System-panel `PlatformUser` actors do not get a separate persisted locale preference in v1. Exports, audit logs, stored report content, raw JSON, and machine-readable identifiers remain unchanged and non-localized. +- **RBAC**: Authenticated workspace-bound users may set or clear their own personal locale preference and temporary explicit override on admin/tenant surfaces. Workspace owners/managers may change the workspace default locale on the existing workspace settings surface. System-panel actors may use the explicit override only in v1 and otherwise inherit the system default. Existing workspace and tenant membership checks remain authoritative. Wrong-plane and non-member access stays 404, and missing capability on workspace settings stays 403. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: Locale changes MUST NOT alter existing tenant-context defaults, current filters, query ownership, search scoping, or canonical list routing. The current tenant/workspace context remains authoritative and language selection only affects presentation. +- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace and tenant isolation remain authoritative. Locale selection MUST NOT reveal inaccessible tenants, operations, findings, or global-search hints through translated labels, fallback strings, or locale-specific navigation branches. + +## 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 +- **Interaction class(es)**: navigation, auth copy, status messaging, action labels, notifications, validation/system texts, dashboard signals, evidence/report viewers, relative-time and date labels +- **Systems touched**: Laravel translator, current `lang/*` catalogs, Filament panel providers, existing auth page copy, workspace settings infrastructure, workspace-bound user model preference storage, system-panel actor handling, Livewire request handling, Filament notifications, and core governance/detail Blade and resource surfaces +- **Existing pattern(s) to extend**: current `__()` usage, existing domain language files, Filament vendor translation layer, existing workspace settings stack, and the current user/session context path +- **Shared contract / presenter / builder / renderer to reuse**: existing translation helpers and settings infrastructure remain canonical; this slice adds one bounded locale resolver and one supported-locale allowlist rather than a second presentation framework +- **Why the existing shared path is sufficient or insufficient**: The current translator and extracted keys are sufficient for rendering translated copy, but they are insufficient because the repo has no single locale resolution contract, no workspace default locale, no workspace-bound user preference path, and no guard against mixed raw English plus translated key usage on the same surfaces. +- **Allowed deviation and why**: none. No surface may invent a local locale source, inline hard-coded German strings, or page-local fallback behavior. +- **Consistency impact**: Canonical glossary terms such as Finding, Baseline, Drift, Risk Accepted, Evidence Gap, Alert, and Run must stay semantically aligned across shell navigation, dashboard tiles, findings/detail views, compare surfaces, notifications, and customer-safe report viewers. +- **Review focus**: Reviewers must verify one shared locale resolver contract, the narrower system-panel source set, no mixed-language operator surface after translation extraction, no raw keys in the rendered UI, and unchanged tenant/workspace authorization semantics. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +N/A - no `OperationRun` start, completion, dedupe, or link semantics are changed by this slice. Existing run-related copy becomes locale-aware, but the run lifecycle contract remains unchanged. + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +N/A - no shared provider/platform boundary is changed. Localization must translate platform vocabulary without importing provider-specific aliases into platform-core truth. + +## 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 | +|---|---|---|---|---|---|---| +| Panel shell, auth, and locale controls | yes | Native Filament panels plus existing auth page | navigation, user-menu/profile actions, auth/system texts | shell, detail, URL-query/session | no | No new panel or shell type is introduced | +| Core governance surfaces | yes | Mixed native Filament resources/pages plus existing Blade views | status messaging, table labels, empty states, glossary terms | page, detail | no | First wave only: dashboard, Findings, Baseline Compare, high-signal tenant/workspace management tables | +| Monitoring and operational feedback surfaces | yes | Mixed native Filament pages/widgets and shared presenters | notifications, alerts, run/status labels, relative time | page, detail | no | Start/completion semantics stay unchanged; only copy/formatting is localized | +| Customer-safe review/report consumption surfaces | yes | Native Filament detail/report viewers | report titles, helper text, read-only evidence/report language | page, detail | no | Machine-readable report payloads and downloads remain invariant | + +## 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 | +|---|---|---|---|---|---|---|---| +| Panel shell, auth, and locale controls | Primary Decision Surface | User decides which language the product should use for the current session or their persisted preference | Current language, available language choices, and whether workspace default applies | Workspace-default explanation and reset-to-default detail | Primary because this is the only place where language is intentionally changed | Keeps language choice inside the shell instead of hidden product-by-product | Removes manual browser-translation workarounds and support explanations | +| Core governance surfaces | Primary Decision Surface | Operator interprets findings, compare results, and tenant/workspace state | Localized headings, state text, actions, empty states, and glossary-consistent labels | Existing raw evidence and detailed diagnostics remain secondary | Primary because these are the actual governance decision surfaces that must be readable first | Preserves the current governance workflow while making the first read understandable in the chosen language | Reduces operator reconstruction of English-only labels and mixed terminology | +| Monitoring and operational feedback surfaces | Secondary Context Surface | Operator interprets toasts, alerts, validation errors, and relative-time context while working | Localized notification titles/bodies, validation/system copy, and relative-time labels | Existing run detail, technical diagnostics, and raw payloads remain secondary | Not primary because these surfaces support ongoing work rather than replace the main decision pages | Keeps supporting feedback aligned with the main locale choice | Avoids context switching between translated pages and English-only feedback | +| Customer-safe review/report consumption surfaces | Tertiary Evidence / Diagnostics Surface | Customer or operator reads existing review/report content | Localized viewer shell, labels, and helper text with invariant machine data | Raw evidence and exported artifacts remain non-localized or separately scoped | Not primary because these surfaces answer evidence questions rather than control product configuration | Preserves read-only review/report flows without creating a separate localization program | Avoids mixed-language read-only experiences during customer handoff | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Panel shell, auth, and locale controls | operator-MSP, support-platform | Current language, override source, and clear change/reset actions | Workspace-default source and personal-preference explanation | No raw settings or session payload by default | `Switch language` or `Use workspace default` | Session mechanics and internal storage details stay hidden | The surface states one resolved language and one source instead of listing multiple conflicting candidates | +| Core governance surfaces | operator-MSP, support-platform | Localized titles, statuses, actions, and empty-state guidance | Existing diagnostics and technical detail stay secondary | Raw JSON/provider payloads remain hidden or capability-gated as today | Existing primary governance action remains primary | Translation-key details and raw glossary mappings stay hidden | Canonical glossary terms are translated once and reused across related surfaces | +| Monitoring and operational feedback surfaces | operator-MSP, support-platform | Localized toasts, validation errors, and relative time/context labels | Existing technical diagnostics on run/detail pages | Raw support payloads remain collapsed or gated | Existing remediation or navigation action remains primary | Internal fallback markers and untranslated key debugging stay hidden | The same message family is localized centrally rather than page-local per toast | +| Customer-safe review/report consumption surfaces | customer-read-only, operator-MSP | Localized shell copy and helper labels around existing review/report content | Existing provenance and review history remain secondary | Machine payloads and exported artifact data stay stable and non-localized | Existing `View` or `Download` action remains primary | Support/raw detail remains gated or omitted | Viewer shell text localizes without creating a second localized export format | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Panel shell, auth, and locale controls | Global context shell | Shell + preference control | Change the current language or reset to inherited default | Shell affordance plus existing settings/profile surface | forbidden | Existing user-menu or settings placement remains secondary to main navigation | none | `/admin` and `/system` shells | existing user/profile or workspace settings surface | Current panel, workspace context, and current locale source | Language / Locale | Current language and override source | Acceptable shell exception because locale is a true global context choice | +| Core governance surfaces | List / Detail / Dashboard | Native resource/page family | Continue the current governance action using translated labels | Existing row click, detail pages, and dashboard cards stay unchanged | existing | Existing More/detail header actions remain where they already live | unchanged | existing governance collection routes under `/admin` | existing governance detail routes under `/admin` | Active workspace and tenant context remain unchanged | Findings, Baselines, Alerts, Runs | Localized decision copy and canonical glossary terms | No new surface type introduced | +| Monitoring and operational feedback surfaces | Monitoring / Status / Feedback | Widget/page plus transient notification families | Act on a toast, alert, validation error, or monitoring label | Existing page/widget affordances stay unchanged | existing where already supported | Existing secondary navigation remains secondary | unchanged | existing monitoring and operations routes | existing run/alert detail routes | Active workspace, run, or alert context remains unchanged | Alerts / Operations / Notifications | Localized feedback copy and relative time labels | Feedback-only translation; no action hierarchy change | +| Customer-safe review/report consumption surfaces | Detail / Report viewer / Download | Read-only viewer family | Read or download the current review/report content | Existing read-only view/download surfaces | existing where already supported | Existing navigation remains secondary | none | existing review/report collections | existing review/report detail routes | Active workspace and tenant context remain unchanged | Review / Report / Evidence | Localized shell labels around stable artifact truth | No new localized export format is introduced | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Panel shell, auth, and locale controls | Any authenticated operator | Decide which language the product should render now | Global shell and settings detail | Which language should this product view use? | Current language, current source, available choices | Internal source precedence and fallback explanation | locale source, current locale | TenantPilot only | Switch language, clear override, save preference, save workspace default | none | +| Core governance surfaces | Workspace operator or platform reviewer | Interpret governance state and continue the current action in the chosen language | List/detail/dashboard family | What needs action right now, in a language I can reliably read? | Localized titles, labels, statuses, empty-state guidance, and action labels | Existing raw evidence and diagnostics | governance state, lifecycle/readiness, data completeness | none beyond existing actions | Existing primary governance actions | existing dangerous actions remain unchanged | +| Monitoring and operational feedback surfaces | Workspace operator or support operator | Understand transient system feedback and supporting context | Feedback/status family | What did the system just tell me, and what should I do next? | Localized notifications, validation messages, and relative-time context | Existing run/alert diagnostic detail | message intent, run status, alert state | none beyond existing actions | Existing follow-up navigation or retry actions | existing dangerous actions remain unchanged | +| Customer-safe review/report consumption surfaces | Customer-safe reader or workspace operator | Read review/report content with translated viewer chrome | Read-only viewer family | What does this review/report say in my chosen language without changing the underlying evidence? | Localized headings, section labels, helper text, and stable dates/times formatting | Existing provenance and support-only detail | report state, artifact availability | none | Existing view/download actions | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: yes - one resolved locale chain becomes current-release presentation truth, but it derives from existing config plus one user preference and one workspace default rather than a new generic preferences framework +- **New persisted entity/table/artifact?**: yes - one personal locale preference field and one workspace locale default setting +- **New abstraction?**: yes - one bounded locale resolver and one request-time application seam +- **New enum/state/reason family?**: yes - the supported locale set is explicitly bounded to `en` and `de` +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Operators and customer-safe readers currently encounter a partially translated product with no reliable way to select or inherit a language across shell, actions, notifications, and core governance views. +- **Existing structure is insufficient because**: Existing `__()` usage and two language files provide raw translation capability, but there is no central locale source, no persisted workspace default, no personal override, and no governed first-wave translation scope. +- **Narrowest correct implementation**: Keep the locale set at two languages, add one resolver where admin/tenant panels use the full precedence chain and the system panel uses explicit override plus system default, persist only one workspace-bound user preference and one workspace default, translate the highest-signal operator/report surfaces first, and leave exports/audit/machine artifacts untouched. +- **Ownership cost**: Ongoing maintenance of EN/DE catalogs, translation-key governance for the selected surface families, and regression tests for fallback and invariant machine outputs. +- **Alternative intentionally rejected**: A generic user-settings registry, multi-locale plugin system, or page-by-page translation without a resolver was rejected because the current release only needs one trustworthy locale chain and a bounded first-wave translation set. +- **Release truth**: current-release truth. The platform already has outward-facing review/report and governance workflows that need a consistent locale foundation now. + +### 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**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: Unit coverage proves locale precedence, supported-locale validation, fallback behavior, and invariant machine-format decisions. Focused feature coverage proves request-time application across Filament and auth surfaces, workspace/user preference flows, translated core surfaces, localized notifications/validation, and unchanged export/audit semantics without requiring browser or heavy-governance lanes. +- **New or expanded test families**: one bounded locale resolver unit family plus focused localization feature coverage for preferences, core governance surfaces, notifications/validation, and fallback/invariant behavior +- **Fixture / helper cost impact**: low. Add only user, workspace, workspace membership, workspace setting, session override, and representative governance surface fixtures. Avoid browser harness growth and avoid a generic translation-seeding framework. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: global-context-shell, standard-native-filament, shared-detail-family +- **Standard-native relief or required special coverage**: Standard Filament feature coverage is sufficient for panel shell and settings surfaces. Global-context-shell coverage is required for locale precedence and request/application flow. Shared-detail-family coverage is required for localized review/report viewer chrome without altering machine-readable content. +- **Reviewer handoff**: Reviewers must confirm the precedence chain is deterministic, unsupported locales fail safely to English, Livewire requests preserve the resolved locale, critical governance surfaces stop mixing English raw strings with translated keys, relative times and validation messages localize correctly, and CSV/JSON/audit artifacts stay stable. +- **Budget / baseline / trend impact**: low feature-local increase only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Localization/LocaleResolverTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php tests/Feature/Localization/LocalePreferenceFlowTest.php tests/Feature/Localization/WorkspaceDefaultLocaleTest.php tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/LocalizedNotificationFormattingTest.php tests/Feature/Localization/TranslationFallbackGuardTest.php tests/Feature/Localization/MachineFormatInvarianceTest.php` + +## Scope Boundaries *(required for this slice)* + +### In Scope + +- One shared locale resolver contract where admin and tenant panels use `explicit override -> user preference -> workspace default -> system default` and the system panel uses `explicit override -> system default` +- Exactly two supported locales in v1: `en` and `de` +- Workspace-owned default locale using the existing workspace settings infrastructure +- User-owned personal locale preference and a clear reset-to-inherited behavior +- Request-time locale application across current Filament admin, tenant, and system/auth flows, including Livewire requests, with the system panel limited to explicit override plus system default +- First-wave translation coverage for panel shell/navigation/auth plus the highest-signal governance surfaces already present in the repo +- Localized notifications, validation/system messages, and relative-time/date/number formatting for those in-scope surfaces +- Controlled English fallback with no raw translation keys rendered in the UI +- Explicit preservation of invariant exports, audit logs, JSON payloads, IDs, and machine-readable report artifacts + +### Non-Goals + +- `apps/website` localization or public documentation translation +- More than two locales in v1 +- A generic user preferences framework or multi-tenant localization framework +- Provider/API payload translation or localized stored evidence/report artifacts +- Full outbound email template localization beyond the minimum auth/system texts already on the current panel flow +- Search ranking, sorting rules, or global-search behavior changes beyond verifying locale safety +- Pseudolocalization as a full product lane; at most one bounded smoke/guard check may support the initial implementation +- A separate persisted locale-preference store for `PlatformUser` system actors + +## Assumptions + +- `config('app.locale')` and `config('app.fallback_locale')` remain the system-default and fallback anchors, both currently English. +- English remains the controlled fallback whenever a German translation key is missing. +- Workspace default locale is a presentation preference only. It does not change authorization, tenant/workspace scope, or machine-readable data. +- System-panel actors in v1 use explicit override plus system default only; they do not inherit workspace default or a second persisted preference store. +- Relative time, date, and number formatting should follow the resolved locale on operator-facing surfaces, but stored timestamps, raw payloads, exports, and audit values remain unchanged. +- The first-wave translation scope is bounded to shell/auth/settings plus existing high-signal governance pages already present in the repo, not every product page. + +## Risks + +- Mixed inline `__('Raw English')` usage and keyed translation files can leave surfaces partially translated if extraction rules are not explicit. +- Livewire and panel-specific middleware may drift if locale application is only added to one request path. +- Localizing viewer chrome while keeping exports/audit invariant can be confused if teams try to translate machine-readable payloads later. +- The glossary can drift between `findings`, `baseline-compare`, and generic panel labels if key ownership is not explicit. + +## Deferred Adjacent Candidates + +- Full public website localization remains a separate website-track concern. +- Broad email/template localization, knowledge-base localization, and public documentation translation stay deferred until the operator-facing runtime foundation is stable. +- Additional locales beyond German and English stay deferred until the two-locale workflow, fallback behavior, and glossary governance are proven. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Resolve and persist one trustworthy locale per request (Priority: P1) + +As an authenticated operator, I want the platform to resolve one language deterministically for the current request so I can work in my chosen language without manually reinterpreting each page. + +**Why this priority**: Without one shared locale chain, every later translation task remains fragile and page-local. + +**Independent Test**: Set a workspace default locale, optionally set a personal locale preference, optionally trigger an explicit override, and verify that admin/tenant requests use the full chain while system-panel requests use explicit override plus system default on both normal and Livewire-backed panel pages. + +**Acceptance Scenarios**: + +1. **Given** a user has no personal locale preference and no explicit override, **When** they open the panel inside a workspace with default locale `de`, **Then** the request resolves to `de` and the shell renders German copy. +2. **Given** the same workspace default is `de` but the user has personal locale preference `en`, **When** they open the panel, **Then** the request resolves to `en`. +3. **Given** the user currently resolves to `en`, **When** they set an explicit temporary override to `de`, **Then** the current request or session resolves to `de` until the override is cleared. +4. **Given** an unsupported locale value is supplied through the temporary override or persisted input, **When** the request resolves locale, **Then** the system safely falls back to English and never exposes raw keys. +5. **Given** a system-panel actor has no explicit override active, **When** they open the system panel, **Then** the request resolves from the system default and does not inherit workspace default or a persisted personal preference in v1. + +--- + +### User Story 2 - Read core governance surfaces in the chosen language (Priority: P1) + +As a workspace or platform operator, I want core governance surfaces to render consistent translated copy and glossary terms so I can make decisions without mixed-language UI fragments. + +**Why this priority**: Dashboard, Findings, Baseline Compare, and the main shell are the highest-signal operating surfaces. If they remain mixed-language, the locale foundation is not credible. + +**Independent Test**: Open the shell, dashboard, Findings, Baseline Compare, and one representative tenant/workspace management table in both `en` and `de`, and verify that headings, status labels, actions, empty states, and relative-time helper text match the resolved locale. + +**Acceptance Scenarios**: + +1. **Given** the resolved locale is `de`, **When** an operator opens the dashboard and Findings pages, **Then** navigation labels, headings, actions, and empty-state/helper text render in German using the canonical glossary. +2. **Given** the resolved locale is `en`, **When** the same operator opens Baseline Compare and the representative management tables, **Then** those surfaces render English labels and status text with unchanged workflow semantics. +3. **Given** a translation key is missing in German for an in-scope surface, **When** the page renders, **Then** the surface falls back to English instead of showing a raw translation key. + +--- + +### User Story 3 - Keep notifications and machine-readable artifacts truthful at the same time (Priority: P1) + +As an operator or customer-safe reader, I want notifications and viewer shell copy to follow my language while exports, audit entries, and machine-readable report content remain stable. + +**Why this priority**: Supportive system feedback and read-only report consumption are part of the real product experience, but they must not compromise machine-format stability or audit truth. + +**Independent Test**: Trigger representative notifications and validation errors in `de` and `en`, open a customer-safe review/report viewer, and verify that UI shell copy localizes while exported/report payloads and audit records stay invariant. + +**Acceptance Scenarios**: + +1. **Given** the resolved locale is `de`, **When** an operator triggers a representative notification or validation error on an in-scope surface, **Then** the message renders in German. +2. **Given** the resolved locale is `de`, **When** a customer-safe reader opens an existing review or report surface, **Then** the viewer chrome and helper labels render in German while underlying machine-readable content stays unchanged. +3. **Given** the same action produces an audit entry, CSV, JSON, or stored machine-readable artifact, **When** the localized UI renders around it, **Then** the artifact content and identifiers remain stable and non-localized. + +### Edge Cases + +- Unsupported locale input must never break request rendering or show raw translation keys. +- Livewire requests must preserve the already-resolved locale instead of silently reverting to the app default. +- Clearing a personal locale preference must return the user to workspace-default behavior, not leave a stale session override in place. +- A user outside any active workspace must still resolve locale safely from explicit override, personal preference, or system default. +- Global-search scope, filter semantics, and authorization outcomes must remain unchanged regardless of locale. +- Relative time labels must localize correctly without mutating stored timestamps or serialized API/export values. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature changes runtime presentation behavior and writes one workspace-bound user-owned preference plus one workspace-owned default, but it introduces no new Microsoft Graph path, no provider dispatch change, and no new queued workflow family. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces one new bounded resolver and two-locale state set because current operator workflows already need a deterministic language source. A narrower page-local translation effort would not solve current-release truth. + +**Constitution alignment (XCUT-001):** All in-scope panels, notifications, and translated core surfaces must consume the same locale resolver contract. No page may invent a second locale source, and the system panel remains on the explicit-override plus system-default subset in v1. + +**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** Localization must improve decision-first readability without exposing support/raw data or changing current disclosure boundaries. + +**Constitution alignment (PROV-001):** Platform vocabulary remains platform-core. Localization translates the vocabulary but does not rename provider-specific identifiers or alter provider payload truth. + +**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit plus feature lanes with minimal fixtures and no browser-lane expansion. + +**Constitution alignment (RBAC-UX):** Language selection MUST NOT alter workspace or tenant access checks, wrong-plane 404 handling, or membership/capability semantics. + +**Constitution alignment (BADGE-001):** Existing status badges and state labels may be translated, but they must continue to use shared badge/state semantics rather than page-local language mappings. + +**Constitution alignment (UI-FIL-001):** The slice extends existing Filament pages, resources, widgets, and the current auth surface. It must not create a custom localization panel or second shell. + +**Constitution alignment (UI-NAMING-001):** Primary operator labels remain domain-specific and translate the same canonical nouns (`Finding`, `Baseline`, `Drift`, `Run`, `Alert`, `Workspace`, `Tenant`) consistently. + +**Constitution alignment (DECIDE-001):** Locale choice is made once at the shell/settings level; core governance surfaces consume it without becoming configuration pages themselves. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** Existing inspect/open models and action hierarchies remain unchanged. This slice changes copy and preference controls only. + +**Constitution alignment (UI-SEM-001 / LAYER-001):** One small locale resolver and one supported-locale catalog are justified because current code lacks a single request-level language decision. No generic translation framework or theme layer is allowed. + +### Functional Requirements + +- **FR-252-001 Supported locales**: The system MUST support exactly two locales in v1: `en` and `de`. +- **FR-252-002 Deterministic precedence**: The effective locale for admin and tenant web requests MUST resolve in this order: explicit override, user preference, workspace default, system default. System-panel requests MUST resolve in this order in v1: explicit override, system default. +- **FR-252-003 Safe validation**: Unsupported or malformed locale values MUST be rejected or normalized safely and MUST fall back to English rather than rendering raw translation keys. +- **FR-252-004 User-owned preference**: The system MUST allow an authenticated workspace-bound user to save, change, or clear their own personal locale preference without affecting other users. +- **FR-252-005 Workspace-owned default**: The system MUST allow authorized workspace operators to set or clear a workspace default locale using the existing workspace settings infrastructure for admin and tenant panel inheritance only. +- **FR-252-006 Reset-to-inherited behavior**: Clearing a user preference MUST cause the locale chain to resume using workspace default or system default. +- **FR-252-007 Request-time application**: The resolved locale MUST apply consistently to normal web requests, auth flows, and Livewire requests for the in-scope panels, with the system panel using the v1 subset of explicit override plus system default only. +- **FR-252-008 Shell and auth coverage**: The system MUST localize the panel shell, navigation labels, auth/login copy, and the locale control affordance itself for the supported locales. +- **FR-252-009 Core surface coverage**: The first implementation slice MUST localize the selected high-signal governance surfaces: dashboard, Findings, Baseline Compare, representative workspace/tenant management tables, monitoring, alerts, operations support labels, and customer-safe review/report viewer shell text. +- **FR-252-010 Canonical glossary consistency**: The translated surface families MUST use one consistent glossary for core governance terms such as Finding, Baseline, Drift, Risk Accepted, Evidence Gap, Alert, and Run. +- **FR-252-011 Notification and validation coverage**: In-scope Filament notifications, validation messages, and system helper text MUST render in the resolved locale. +- **FR-252-012 Locale-aware formatting**: In-scope date, time, number, and relative-time labels shown on operator-facing surfaces MUST respect the resolved locale. +- **FR-252-013 Controlled fallback**: Missing translations in German MUST fall back to English. Raw translation keys MUST NOT appear on the rendered UI for in-scope surfaces. +- **FR-252-014 Invariant machine formats**: CSV, JSON, audit logs, stored artifacts, machine-readable report data, IDs, and provider/raw evidence payloads MUST remain stable and non-localized. +- **FR-252-015 Authorization invariance**: Locale changes MUST NOT alter route access, wrong-plane or non-member 404 behavior, member-but-no-capability 403 behavior, global-search visibility, filter scope, tenant/workspace context, or any other RBAC outcome. +- **FR-252-016 No parallel locale sources**: In-scope pages, widgets, resources, notifications, and viewers MUST consume the shared resolved locale and MUST NOT implement page-local language sources. +- **FR-252-017 Bounded v1**: This slice MUST NOT add website localization, more than two locales, a generic preferences framework, or a broad translation program for every product 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| User locale control | existing panel shell or self-profile surface | none on collection | N/A | none | none | N/A | `Save language`, `Use inherited default`, `Reset override` | existing save/cancel pattern if profile/settings form is used | no | Global shell exception is intentional because locale is a true shell concern | +| Workspace default locale setting | existing workspace settings surface | existing settings navigation only | N/A | none | none | N/A | existing workspace settings save action includes locale field | existing save/cancel pattern stays authoritative | yes - through existing settings audit path | No destructive action is introduced | +| Core governance surfaces | existing dashboard, resources, and pages | unchanged | existing inspect affordances remain unchanged | unchanged | unchanged | unchanged | unchanged except translated labels and copy | N/A | no new audit requirement | Translation only; no action hierarchy change | +| Monitoring and customer-safe review/report surfaces | existing pages, resources, and viewers | unchanged | existing inspect affordances remain unchanged | unchanged | unchanged | unchanged | unchanged except translated labels and copy | N/A | no new audit requirement | Viewer chrome localizes; artifact content stays stable | + +### Key Entities *(include if feature involves data)* + +- **Resolved locale context**: Derived request-time value with the selected locale plus source (`explicit_override`, `user_preference`, `workspace_default`, `system_default`); system-panel requests only use the explicit-override or system-default subset in v1 +- **User locale preference**: Workspace-bound user-owned persisted preference for `en` or `de` +- **Workspace default locale**: Workspace-owned default locale stored through the existing settings infrastructure for admin/tenant inheritance +- **Translation catalogs**: Bounded EN/DE language files covering the selected first-wave surface families \ No newline at end of file diff --git a/specs/252-platform-localization-v1/tasks.md b/specs/252-platform-localization-v1/tasks.md new file mode 100644 index 00000000..bcb609e6 --- /dev/null +++ b/specs/252-platform-localization-v1/tasks.md @@ -0,0 +1,187 @@ +--- + +description: "Task list for feature implementation" +--- + +# Tasks: Platform Localization v1 (DE/EN) + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md` + +**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in focused `Unit` plus `Feature` lanes only, using the targeted Sail commands already captured in the feature spec, plan, and quickstart artifacts. + +## Test Governance Notes + +- Lane assignment: `fast-feedback` and `confidence` are the narrowest sufficient proof for locale precedence, request-time application, translated core surfaces, feedback localization, fallback safety, authorization invariance, and invariant machine-format behavior. +- Keep new coverage inside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Localization/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/Localization/`; do not widen this slice into browser or heavy-governance families. +- Reuse existing user, workspace, membership, workspace-setting, and representative governance surface fixtures. Any new helper must stay opt-in and cheap by default. +- If one late surface remains English-only for a bounded reason, record it as `document-in-feature` in close-out instead of widening scope into a broader localization program. + +## Scope Control Notes + +- Keep implementation inside one locale resolver contract, one workspace-bound user preference path, one workspace default path, current panel/auth/Livewire request application, first-wave translation coverage for shell plus high-signal governance surfaces, and invariant export/audit/raw payload behavior. +- Do not add website localization, more than two locales, a generic preferences framework, a second persisted preference store for system `PlatformUser` actors, public documentation translation, or broad email-template localization. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Lock the bounded slice, translation inventory, and validation plan before runtime edits begin. + +- [x] T001 Review the bounded slice, explicit non-goals, and outcome class in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/checklists/requirements.md` +- [x] T002 [P] Review locale precedence, persistence ownership, and invariance boundaries in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/data-model.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml` +- [x] T003 [P] Confirm the focused Sail/Pest proof commands and manual smoke expectations in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the shared locale primitives that every user story depends on. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [x] T004 [P] Add the supported-locale allowlist, precedence evaluation, and fallback behavior in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Localization/LocaleResolver.php` +- [x] T005 [P] Add the personal locale preference field and model support in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/database/migrations/*_add_preferred_locale_to_users_table.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Models/User.php` +- [x] T006 [P] Register the workspace default locale definition and persistence path in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Settings/SettingsRegistry.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsResolver.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsWriter.php` +- [x] T007 Thread locale application through `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/bootstrap/app.php`, a new `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Middleware/ApplyResolvedLocale.php`, and the current panel providers in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Providers/Filament/`, with explicit override plus system default only on the system panel + +**Checkpoint**: Foundation ready. Locale precedence, persistence, and request-time application now exist without any page-local language logic. + +--- + +## Phase 3: User Story 1 - Choose and inherit language predictably (Priority: P1) 🎯 MVP + +**Goal**: Let users inherit workspace default language, override it personally, and apply a temporary explicit override from the current shell. + +**Independent Test**: Set workspace default locale, set or clear a personal preference, apply or clear an explicit override, and verify the effective locale on normal, auth, system-panel, and Livewire-backed panel requests, with the system panel using explicit override plus system default only. + +### Tests for User Story 1 + +- [x] T008 [P] [US1] Add unit and feature coverage for precedence ordering, unsupported-locale fallback, reset-to-inherited behavior, auth and system-panel rendering, wrong-plane or non-member 404, member-but-no-capability 403, system-panel explicit-override plus system-default behavior, and Livewire continuity in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Localization/LocaleResolverTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/LocalePreferenceFlowTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/WorkspaceDefaultLocaleTest.php` + +### Implementation for User Story 1 + +- [x] T009 [US1] Add the user-facing locale control to the existing shared shell surface in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/partials/context-bar.blade.php` and any required panel-provider wiring in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Providers/Filament/` +- [x] T010 [US1] Add the workspace default locale field to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` using the current workspace settings save and audit path, including clear-to-inherit behavior back to the system default +- [x] T011 [US1] Localize shell and auth copy across `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Auth/Login.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Auth/Login.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/partials/context-bar.blade.php` + +**Checkpoint**: User Story 1 is independently functional when language choice is deterministic and visible on the existing shell and settings surfaces. + +--- + +## Phase 4: User Story 2 - Read core governance surfaces in the chosen language (Priority: P1) + +**Goal**: Translate the first-wave governance surfaces so operators can work in English or German without mixed-language UI fragments. + +**Independent Test**: Open the shell, tenant dashboard, system dashboard, Findings, Baseline Compare, and representative workspace or tenant management tables in both locales and verify headings, actions, empty states, and glossary terms align with the resolved locale. + +### Tests for User Story 2 + +- [x] T012 [P] [US2] Add feature coverage for translated shell, tenant dashboard, system dashboard, and first-wave governance surface rendering and fallback behavior in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/TranslationFallbackGuardTest.php` + +### Implementation for User Story 2 + +- [x] T013 [US2] Extract and add translation catalogs for shell, tenant and system dashboard surfaces, Findings, Baseline Compare, and representative management tables in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/`, and the touched page/resource/view files +- [x] T014 [US2] Localize relative-time, date, and number formatting on the in-scope dashboard and governance surfaces in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/`, and any related support presenters +- [x] T015 [US2] Keep search, filter, row-click, global-search, wrong-plane or non-member 404, member-but-no-capability 403, and scope semantics unchanged while translated labels render on the in-scope shell and governance surfaces + +**Checkpoint**: User Story 2 is independently functional when the first-wave operator surfaces render coherent EN or DE copy without changing workflow semantics. + +--- + +## Phase 5: User Story 3 - Localize feedback without changing machine truth (Priority: P1) + +**Goal**: Make notifications, validation text, monitoring, alert, and operations feedback copy, plus customer-safe viewer chrome, locale-aware while exported and audited machine-readable content stays invariant. + +**Independent Test**: Trigger representative notifications and validation messages, open a customer-safe review or report viewer, and confirm surrounding UI copy localizes while audit, export, and raw payload truth stays stable. + +### Tests for User Story 3 + +- [x] T016 [P] [US3] Add feature coverage for localized notifications, validation and helper text, customer-safe viewer chrome, and machine-format invariance in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/LocalizedNotificationFormattingTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/MachineFormatInvarianceTest.php` + +### Implementation for User Story 3 + +- [x] T017 [US3] Localize in-scope notifications, validation/system messages, and monitoring, alert, or operations feedback copy in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Livewire/`, and related view files +- [x] T018 [US3] Localize customer-safe review or report viewer shell copy while preserving invariant exports, audit values, IDs, and raw payload truth in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`, and related viewer or widget views + +**Checkpoint**: User Story 3 is independently functional when localized feedback and viewer shells coexist with unchanged machine-readable artifact truth. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Run the narrow validation lanes, format touched files, and capture the feature-local close-out without widening scope. + +- [x] T019 Run the targeted unit Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Localization/LocaleResolverTest.php` +- [x] T020 Run the targeted feature Sail/Pest commands from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/LocalePreferenceFlowTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/WorkspaceDefaultLocaleTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/LocalizedNotificationFormattingTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/TranslationFallbackGuardTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/MachineFormatInvarianceTest.php` +- [x] T021 Run dirty-only Pint through Sail for touched platform files using the command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md` +- [x] T022 Record the final guardrail close-out, lane results, workflow outcome (`keep` unless implementation proves otherwise), and any bounded `document-in-feature` note for temporarily untranslated surfaces in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/checklists/requirements.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: starts immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories. +- **Phase 3 (US1)**, **Phase 4 (US2)**, and **Phase 5 (US3)**: each depends on Phase 2 and is independently testable after shared locale resolution and persistence exist. +- **Phase 6 (Polish)**: depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: first shippable increment once Phase 2 is complete. +- **US2 (P1)**: independently testable after Phase 2 and should follow US1 because translated shell and precedence behavior must already be stable. +- **US3 (P1)**: independently testable after Phase 2 and should merge after US1 because notifications, alert and operations feedback, and viewer shells depend on the same locale foundation. + +### Within Each User Story + +- Write the listed Pest coverage first and make it fail for the intended gap before implementation. +- Complete the shared resolver or persistence seam before wiring multiple UI entry points that depend on it. +- Re-run the narrowest relevant proof command after each story checkpoint before moving to the next story. + +--- + +## Parallel Opportunities + +### Phase 1 + +- T002 and T003 can run in parallel after T001 confirms the bounded slice. + +### Phase 2 + +- T004, T005, and T006 can run in parallel. +- T007 should follow once resolver and persistence keys exist. + +### User Story 1 + +- T008 can run in parallel with any final Phase 2 cleanup. +- T009 and T010 can proceed in parallel once the persistence shape is stable. +- T011 should follow after the locale-control surface exists. + +### User Story 2 + +- T012 can run in parallel with final US1 validation once Phase 2 is complete. +- T013 and T014 can run in parallel after the first-wave inventory is fixed. +- T015 should follow to confirm semantics remain unchanged. + +### User Story 3 + +- T016 can run in parallel with late US2 verification. +- T017 and T018 can run in parallel once the locale foundation is stable. + +--- + +## Implementation Strategy + +### Suggested MVP Scope + +- MVP = **Phase 2 + User Story 1 + User Story 2 + User Story 3**. This is the smallest slice that establishes deterministic locale truth, translates the declared first-wave governance and feedback surfaces, and preserves invariant machine-readable artifacts. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 and validate deterministic locale choice plus inheritance. +3. Deliver US2 and validate translated shell plus first-wave governance surfaces. +4. Deliver US3 and validate localized feedback, alert and operations copy, customer-safe viewer chrome, and invariant machine-readable artifacts. +5. Finish with Phase 6 validation, formatting, and feature-local close-out recording. -- 2.45.2 From 29ad8852ca2ea698ecd441f70af2674745e003ae Mon Sep 17 00:00:00 2001 From: ahmido Date: Tue, 28 Apr 2026 22:11:20 +0000 Subject: [PATCH 27/36] merge: platform-dev into dev (#295) ## Summary - integrate the current `platform-dev` branch into `dev` - bring the latest platform work from the integration branch into the main development branch - include the recent findings lifecycle backfill removal slice together with the already accumulated `platform-dev` changes ## Scope - source branch: `platform-dev` - target branch: `dev` - branch role: integration PR, not a single-feature PR ## Validation - branch state reviewed before PR creation - `platform-dev` is ahead of `dev` with the expected integration history - this PR intentionally carries the accumulated `platform-dev` commits into `dev` ## Notes - this is the correct merge direction for the current workflow, where feature branches land in `platform-dev` first and `platform-dev` is then merged into `dev` - after merging, `platform-dev` can be recreated fresh from `dev` as usual Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/295 --- .github/agents/copilot-instructions.md | 4 +- .../TenantpilotBackfillFindingLifecycle.php | 129 --- .../Commands/TenantpilotRunDeployRunbooks.php | 56 -- .../FindingResource/Pages/ListFindings.php | 77 -- .../Filament/System/Pages/Ops/Runbooks.php | 291 ------- .../app/Jobs/BackfillFindingLifecycleJob.php | 398 ---------- ...dingLifecycleTenantIntoWorkspaceRunJob.php | 378 --------- .../BackfillFindingLifecycleWorkspaceJob.php | 95 --- ...indingsLifecycleBackfillRunbookService.php | 739 ------------------ .../FindingsLifecycleBackfillScope.php | 81 -- .../OperationRunTriageService.php | 2 - .../app/Support/Auth/PlatformCapabilities.php | 2 - .../TrustedState/TrustedStatePolicy.php | 88 --- .../platform/app/Support/OperationCatalog.php | 2 - .../ActionSurface/ActionSurfaceExemptions.php | 11 +- .../database/seeders/PlatformUserSeeder.php | 1 - .../system/pages/ops/runbooks.blade.php | 105 +-- ...eFindingsLifecycleBackfillCommandsTest.php | 20 + .../Spec113/DeployRunbooksCommandTest.php | 31 - .../AdminFindingsNoMaintenanceActionsTest.php | 21 - .../Feature/Findings/FindingBackfillTest.php | 136 ---- .../FindingWorkflowRegressionTest.php | 61 ++ ...ationalControlFindingsBackfillGateTest.php | 101 --- ...oveFindingsLifecycleBackfillActionTest.php | 33 + .../Guards/LivewireTrustedStateGuardTest.php | 1 - .../NoAdHocOperationalControlBypassTest.php | 34 +- ...dingsLifecycleBackfillControlTraceTest.php | 42 + ...ingsLifecycleBackfillAuditFailSafeTest.php | 87 --- ...indingsLifecycleBackfillBreakGlassTest.php | 111 --- ...ndingsLifecycleBackfillIdempotencyTest.php | 78 -- ...FindingsLifecycleBackfillPreflightTest.php | 202 ----- .../FindingsLifecycleBackfillStartTest.php | 253 ------ .../OperationalControlRunbookGateTest.php | 89 --- .../OpsUxStartSurfaceContractTest.php | 89 --- ...ngsLifecycleBackfillRunbookSurfaceTest.php | 59 ++ ...gsLifecycleBackfillCapabilityTraceTest.php | 14 + ...dingsLifecycleBackfillCatalogTraceTest.php | 12 + docs/HANDOVER.md | 1 - .../checklists/requirements.md | 48 ++ ...fill-runtime-surface-removal.contract.yaml | 108 +++ .../data-model.md | 121 +++ .../plan.md | 237 ++++++ .../quickstart.md | 36 + .../research.md | 153 ++++ .../spec.md | 291 +++++++ .../tasks.md | 231 ++++++ 46 files changed, 1501 insertions(+), 3658 deletions(-) delete mode 100644 apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php delete mode 100644 apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php delete mode 100644 apps/platform/app/Jobs/BackfillFindingLifecycleJob.php delete mode 100644 apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php delete mode 100644 apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php delete mode 100644 apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php delete mode 100644 apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillScope.php create mode 100644 apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php delete mode 100644 apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php delete mode 100644 apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php delete mode 100644 apps/platform/tests/Feature/Findings/FindingBackfillTest.php create mode 100644 apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php delete mode 100644 apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php create mode 100644 apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php create mode 100644 apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php delete mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php delete mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php delete mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php delete mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php delete mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php delete mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php delete mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php create mode 100644 apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php create mode 100644 apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php create mode 100644 apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php create mode 100644 specs/253-remove-findings-backfill-runtime-surfaces/checklists/requirements.md create mode 100644 specs/253-remove-findings-backfill-runtime-surfaces/contracts/findings-backfill-runtime-surface-removal.contract.yaml create mode 100644 specs/253-remove-findings-backfill-runtime-surfaces/data-model.md create mode 100644 specs/253-remove-findings-backfill-runtime-surfaces/plan.md create mode 100644 specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md create mode 100644 specs/253-remove-findings-backfill-runtime-surfaces/research.md create mode 100644 specs/253-remove-findings-backfill-runtime-surfaces/spec.md create mode 100644 specs/253-remove-findings-backfill-runtime-surfaces/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 80499eb9..910f11be 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -264,6 +264,8 @@ ## Active Technologies - PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace) - PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page (251-commercial-entitlements-billing-state) - PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model (251-commercial-entitlements-billing-state) +- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities` (253-remove-findings-backfill-runtime-surfaces) +- PostgreSQL existing `findings`, `operation_runs`, `audit_logs`, and related runtime tables only; no new persistence, migration, or data backfill is planned (253-remove-findings-backfill-runtime-surfaces) - PHP 8.4.15 (feat/005-bulk-operations) @@ -298,9 +300,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 253-remove-findings-backfill-runtime-surfaces: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities` - 251-commercial-entitlements-billing-state: Added PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page - 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services -- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` ### Pre-production compatibility check diff --git a/apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php b/apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php deleted file mode 100644 index 995cae4e..00000000 --- a/apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php +++ /dev/null @@ -1,129 +0,0 @@ -option('tenant'))); - - if ($tenantIdentifiers === []) { - $this->error('Provide one or more tenants via --tenant={id|external_id}.'); - - return self::FAILURE; - } - - $tenants = $this->resolveTenants($tenantIdentifiers); - - if ($tenants->isEmpty()) { - $this->info('No tenants matched the provided identifiers.'); - - return self::SUCCESS; - } - - $queued = 0; - $skipped = 0; - $nothingToDo = 0; - - foreach ($tenants as $tenant) { - if (! $tenant instanceof Tenant) { - continue; - } - - try { - $run = $runbookService->start( - scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), - initiator: null, - reason: null, - source: 'cli', - ); - } catch (OperationalControlBlockedException $e) { - $this->error(sprintf( - 'Backfill paused for tenant %d: %s', - (int) $tenant->getKey(), - $e->getMessage(), - )); - - return self::FAILURE; - } catch (ValidationException $e) { - $errors = $e->errors(); - - if (isset($errors['preflight.affected_count'])) { - $nothingToDo++; - - continue; - } - - $this->error(sprintf( - 'Backfill blocked for tenant %d: %s', - (int) $tenant->getKey(), - $e->getMessage(), - )); - - return self::FAILURE; - } - - if (! $run->wasRecentlyCreated) { - $skipped++; - - continue; - } - - $queued++; - } - - $this->info(sprintf( - 'Queued %d backfill run(s), skipped %d duplicate run(s), nothing to do %d.', - $queued, - $skipped, - $nothingToDo, - )); - - return self::SUCCESS; - } - - /** - * @param array $tenantIdentifiers - * @return \Illuminate\Support\Collection - */ - private function resolveTenants(array $tenantIdentifiers) - { - $tenantIds = []; - - foreach ($tenantIdentifiers as $identifier) { - $tenant = Tenant::query() - ->forTenant($identifier) - ->first(); - - if ($tenant instanceof Tenant) { - $tenantIds[] = (int) $tenant->getKey(); - } - } - - $tenantIds = array_values(array_unique($tenantIds)); - - if ($tenantIds === []) { - return collect(); - } - - return Tenant::query() - ->whereIn('id', $tenantIds) - ->orderBy('id') - ->get(); - } -} diff --git a/apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php b/apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php deleted file mode 100644 index 1bbce8cb..00000000 --- a/apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php +++ /dev/null @@ -1,56 +0,0 @@ -start( - scope: FindingsLifecycleBackfillScope::allTenants(), - initiator: null, - reason: new RunbookReason( - reasonCode: RunbookReason::CODE_DATA_REPAIR, - reasonText: 'Deploy hook automated runbooks', - ), - source: 'deploy_hook', - ); - - $this->info('Deploy runbooks started (if needed).'); - - return self::SUCCESS; - } catch (OperationalControlBlockedException $e) { - $this->info('Deploy runbooks paused: '.$e->getMessage()); - - return self::SUCCESS; - } catch (ValidationException $e) { - $errors = $e->errors(); - - $skippable = isset($errors['preflight.affected_count']) || isset($errors['scope']); - - if ($skippable) { - $this->info('Deploy runbooks skipped (nothing to do or already running).'); - - return self::SUCCESS; - } - - $this->error('Deploy runbooks blocked by validation errors.'); - - return self::FAILURE; - } - } -} diff --git a/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php b/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php index b46f0a39..d4796c04 100644 --- a/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php +++ b/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php @@ -10,14 +10,8 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Findings\FindingWorkflowService; -use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService; -use App\Services\Runbooks\FindingsLifecycleBackfillScope; use App\Support\Auth\Capabilities; use App\Support\Filament\CanonicalAdminTenantFilterState; -use App\Support\OperationRunLinks; -use App\Support\OpsUx\OperationUxPresenter; -use App\Support\OpsUx\OpsUxBrowserEvents; -use App\Support\OperationalControls\OperationalControlBlockedException; use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiTooltips; use Filament\Actions; @@ -108,77 +102,6 @@ protected function getHeaderActions(): array { $actions = []; - $actions[] = UiEnforcement::forAction( - Actions\Action::make('backfill_lifecycle') - ->label('Backfill findings lifecycle') - ->icon('heroicon-o-wrench-screwdriver') - ->color('gray') - ->requiresConfirmation() - ->modalHeading('Backfill findings lifecycle') - ->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.') - ->action(function (FindingsLifecycleBackfillRunbookService $runbookService): void { - $user = auth()->user(); - - if (! $user instanceof User) { - abort(403); - } - - $tenant = static::resolveTenantContextForCurrentPanel(); - - if (! $tenant instanceof Tenant) { - abort(404); - } - - try { - $opRun = $runbookService->start( - scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), - initiator: $user, - reason: null, - source: 'tenant_ui', - ); - } catch (OperationalControlBlockedException $exception) { - Notification::make() - ->title($exception->title()) - ->body($exception->getMessage()) - ->warning() - ->send(); - - throw new \Filament\Support\Exceptions\Halt; - } - - $runUrl = OperationRunLinks::view($opRun, $tenant); - - if ($opRun->wasRecentlyCreated === false) { - OpsUxBrowserEvents::dispatchRunEnqueued($this); - - OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label('Open operation') - ->url($runUrl), - ]) - ->send(); - - return; - } - - OpsUxBrowserEvents::dispatchRunEnqueued($this); - - OperationUxPresenter::queuedToast((string) $opRun->type) - ->body('The backfill will run in the background. You can continue working while it completes.') - ->actions([ - Actions\Action::make('view_run') - ->label('Open operation') - ->url($runUrl), - ]) - ->send(); - }) - ) - ->preserveVisibility() - ->requireCapability(Capabilities::TENANT_MANAGE) - ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) - ->apply(); - $actions[] = UiEnforcement::forAction( Actions\Action::make('triage_all_matching') ->label('Triage all matching') diff --git a/apps/platform/app/Filament/System/Pages/Ops/Runbooks.php b/apps/platform/app/Filament/System/Pages/Ops/Runbooks.php index 89cc1c02..ed23b75d 100644 --- a/apps/platform/app/Filament/System/Pages/Ops/Runbooks.php +++ b/apps/platform/app/Filament/System/Pages/Ops/Runbooks.php @@ -4,26 +4,9 @@ namespace App\Filament\System\Pages\Ops; -use App\Models\OperationRun; use App\Models\PlatformUser; -use App\Models\Tenant; -use App\Services\Auth\BreakGlassSession; -use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService; -use App\Services\Runbooks\FindingsLifecycleBackfillScope; -use App\Services\Runbooks\RunbookReason; -use App\Services\System\AllowedTenantUniverse; use App\Support\Auth\PlatformCapabilities; -use App\Support\OpsUx\OperationUxPresenter; -use App\Support\OperationalControls\OperationalControlBlockedException; -use App\Support\System\SystemOperationRunLinks; -use Filament\Actions\Action; -use Filament\Forms\Components\Radio; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\Textarea; -use Filament\Forms\Components\TextInput; -use Filament\Notifications\Notification; use Filament\Pages\Page; -use Illuminate\Validation\ValidationException; class Runbooks extends Page { @@ -37,53 +20,6 @@ class Runbooks extends Page protected string $view = 'filament.system.pages.ops.runbooks'; - public string $findingsScopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS; - - public ?int $findingsTenantId = null; - - public string $scopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS; - - public ?int $tenantId = null; - - /** - * @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null - */ - public ?array $findingsPreflight = null; - - /** - * @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null - */ - public ?array $preflight = null; - - public function findingsScopeLabel(): string - { - if ($this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) { - return 'All tenants'; - } - - $tenantName = $this->selectedTenantName($this->findingsTenantId); - - if ($tenantName !== null) { - return "Single tenant ({$tenantName})"; - } - - return $this->findingsTenantId !== null ? "Single tenant (#{$this->findingsTenantId})" : 'Single tenant'; - } - - public function findingsLastRun(): ?OperationRun - { - return $this->lastRunForType(FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY); - } - - public function selectedTenantName(?int $tenantId): ?string - { - if ($tenantId === null) { - return null; - } - - return Tenant::query()->whereKey($tenantId)->value('name'); - } - public static function canAccess(): bool { $user = auth('platform')->user(); @@ -95,231 +31,4 @@ public static function canAccess(): bool return $user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW); } - - /** - * @return array - */ - protected function getHeaderActions(): array - { - return [ - Action::make('preflight') - ->label('Preflight') - ->color('gray') - ->icon('heroicon-o-magnifying-glass') - ->form($this->findingsScopeForm()) - ->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void { - $scope = $this->trustedFindingsScopeFromFormData($data, app(AllowedTenantUniverse::class)); - - $this->findingsScopeMode = $scope->mode; - $this->findingsTenantId = $scope->tenantId; - $this->scopeMode = $scope->mode; - $this->tenantId = $scope->tenantId; - - $this->findingsPreflight = $runbookService->preflight($scope); - $this->preflight = $this->findingsPreflight; - - Notification::make() - ->title('Preflight complete') - ->success() - ->send(); - }), - - Action::make('run') - ->label('Run…') - ->icon('heroicon-o-play') - ->color('danger') - ->requiresConfirmation() - ->modalHeading('Run: Rebuild Findings Lifecycle') - ->modalDescription('This operation may modify customer data. Review the preflight and confirm before running.') - ->form($this->findingsRunForm()) - ->disabled(fn (): bool => ! is_array($this->findingsPreflight) || (int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0) - ->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void { - if (! is_array($this->findingsPreflight) || (int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0) { - throw ValidationException::withMessages([ - 'preflight' => 'Run preflight first.', - ]); - } - - $scope = $this->trustedFindingsScopeFromState(app(AllowedTenantUniverse::class)); - - $user = auth('platform')->user(); - - if (! $user instanceof PlatformUser) { - abort(403); - } - - if (! $user->hasCapability(PlatformCapabilities::RUNBOOKS_RUN) - || ! $user->hasCapability(PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL) - ) { - abort(403); - } - - if ($scope->isAllTenants()) { - $typedConfirmation = (string) ($data['typed_confirmation'] ?? ''); - - if ($typedConfirmation !== 'BACKFILL') { - throw ValidationException::withMessages([ - 'typed_confirmation' => 'Please type BACKFILL to confirm.', - ]); - } - } - - $reason = RunbookReason::fromNullableArray([ - 'reason_code' => $data['reason_code'] ?? null, - 'reason_text' => $data['reason_text'] ?? null, - ]); - - try { - $run = $runbookService->start( - scope: $scope, - initiator: $user, - reason: $reason, - source: 'system_ui', - ); - } catch (OperationalControlBlockedException $exception) { - Notification::make() - ->title($exception->title()) - ->body($exception->getMessage()) - ->warning() - ->send(); - - throw new \Filament\Support\Exceptions\Halt; - } - - $viewUrl = SystemOperationRunLinks::view($run); - - $toast = $run->wasRecentlyCreated - ? OperationUxPresenter::queuedToast((string) $run->type)->body('The runbook will execute in the background.') - : OperationUxPresenter::alreadyQueuedToast((string) $run->type); - - $toast - ->actions([ - Action::make('view_run') - ->label('View run') - ->url($viewUrl), - ]) - ->send(); - }), - ]; - } - - /** - * @return array - */ - private function findingsScopeForm(): array - { - return [ - Radio::make('scope_mode') - ->label('Scope') - ->options([ - FindingsLifecycleBackfillScope::MODE_ALL_TENANTS => 'All tenants', - FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT => 'Single tenant', - ]) - ->default($this->findingsScopeMode) - ->live() - ->required(), - - Select::make('tenant_id') - ->label('Tenant') - ->searchable() - ->visible(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) - ->required(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) - ->getSearchResultsUsing(function (string $search, AllowedTenantUniverse $universe): array { - return $universe - ->query() - ->where('name', 'like', "%{$search}%") - ->orderBy('name') - ->limit(25) - ->pluck('name', 'id') - ->all(); - }) - ->getOptionLabelUsing(function ($value, AllowedTenantUniverse $universe): ?string { - if (! is_numeric($value)) { - return null; - } - - return $universe - ->query() - ->whereKey((int) $value) - ->value('name'); - }), - ]; - } - - /** - * @return array - */ - private function findingsRunForm(): array - { - return [ - TextInput::make('typed_confirmation') - ->label('Type BACKFILL to confirm') - ->visible(fn (): bool => $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) - ->required(fn (): bool => $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) - ->in(['BACKFILL']) - ->validationMessages([ - 'in' => 'Please type BACKFILL to confirm.', - ]), - - Select::make('reason_code') - ->label('Reason code') - ->options(RunbookReason::options()) - ->required(function (BreakGlassSession $breakGlass): bool { - return $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive(); - }), - - Textarea::make('reason_text') - ->label('Reason') - ->rows(4) - ->maxLength(500) - ->required(function (BreakGlassSession $breakGlass): bool { - return $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive(); - }), - ]; - } - - private function lastRunForType(string $type): ?OperationRun - { - $platformTenant = Tenant::query()->where('external_id', 'platform')->first(); - - if (! $platformTenant instanceof Tenant) { - return null; - } - - return OperationRun::query() - ->where('workspace_id', (int) $platformTenant->workspace_id) - ->where('type', $type) - ->latest('id') - ->first(); - } - - /** - * @param array $data - */ - private function trustedFindingsScopeFromFormData(array $data, AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope - { - $scope = FindingsLifecycleBackfillScope::fromArray([ - 'mode' => $data['scope_mode'] ?? null, - 'tenant_id' => $data['tenant_id'] ?? null, - ]); - - if (! $scope->isSingleTenant()) { - return $scope; - } - - $tenant = $allowedTenantUniverse->resolveAllowedOrFail($scope->tenantId); - - return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()); - } - - private function trustedFindingsScopeFromState(AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope - { - if ($this->findingsScopeMode !== FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) { - return FindingsLifecycleBackfillScope::allTenants(); - } - - $tenant = $allowedTenantUniverse->resolveAllowedOrFail($this->findingsTenantId); - - return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()); - } } diff --git a/apps/platform/app/Jobs/BackfillFindingLifecycleJob.php b/apps/platform/app/Jobs/BackfillFindingLifecycleJob.php deleted file mode 100644 index ff20cc37..00000000 --- a/apps/platform/app/Jobs/BackfillFindingLifecycleJob.php +++ /dev/null @@ -1,398 +0,0 @@ -find($this->tenantId); - - if (! $tenant instanceof Tenant) { - return; - } - - $initiator = $this->initiatorUserId !== null - ? User::query()->find($this->initiatorUserId) - : null; - - $operationRun = $operationRuns->ensureRunWithIdentity( - tenant: $tenant, - type: 'findings.lifecycle.backfill', - identityInputs: [ - 'tenant_id' => $this->tenantId, - 'trigger' => 'backfill', - ], - context: [ - 'workspace_id' => $this->workspaceId, - 'initiator_user_id' => $this->initiatorUserId, - ], - initiator: $initiator instanceof User ? $initiator : null, - ); - - $lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900); - - if (! $lock->get()) { - if ($operationRun->status !== OperationRunStatus::Completed->value) { - $operationRuns->updateRun( - $operationRun, - status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::Blocked->value, - failures: [ - [ - 'code' => 'findings.lifecycle.backfill.lock_busy', - 'message' => 'Another findings lifecycle backfill is already running for this tenant.', - ], - ], - ); - } - - $runbookService->maybeFinalize($operationRun); - - return; - } - - try { - $total = (int) Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->count(); - - $operationRuns->updateRun( - $operationRun, - status: OperationRunStatus::Running->value, - outcome: OperationRunOutcome::Pending->value, - summaryCounts: [ - 'total' => $total, - 'processed' => 0, - 'updated' => 0, - 'skipped' => 0, - 'failed' => 0, - ], - ); - - $operationRun->refresh(); - - $backfillStartedAt = $operationRun->started_at !== null - ? CarbonImmutable::instance($operationRun->started_at) - : CarbonImmutable::now('UTC'); - - Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->orderBy('id') - ->chunkById(200, function (Collection $findings) use ($tenant, $slaPolicy, $operationRuns, $operationRun, $backfillStartedAt): void { - $processed = 0; - $updated = 0; - $skipped = 0; - - foreach ($findings as $finding) { - if (! $finding instanceof Finding) { - continue; - } - - $processed++; - - $originalAttributes = $finding->getAttributes(); - - $this->backfillLifecycleFields($finding, $backfillStartedAt); - $this->backfillLegacyAcknowledgedStatus($finding); - $this->backfillSlaFields($finding, $tenant, $slaPolicy, $backfillStartedAt); - $this->backfillDriftRecurrenceKey($finding); - - if ($finding->isDirty()) { - $finding->save(); - $updated++; - } else { - $finding->setRawAttributes($originalAttributes, sync: true); - $skipped++; - } - } - - $operationRuns->incrementSummaryCounts($operationRun, [ - 'processed' => $processed, - 'updated' => $updated, - 'skipped' => $skipped, - ]); - }); - - $consolidatedDuplicates = $this->consolidateDriftDuplicates($tenant, $backfillStartedAt); - - if ($consolidatedDuplicates > 0) { - $operationRuns->incrementSummaryCounts($operationRun, [ - 'updated' => $consolidatedDuplicates, - ]); - } - - $operationRuns->updateRun( - $operationRun, - status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::Succeeded->value, - ); - - $runbookService->maybeFinalize($operationRun); - } catch (Throwable $e) { - $message = RunFailureSanitizer::sanitizeMessage($e->getMessage()); - $reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage()); - - $operationRuns->updateRun( - $operationRun, - status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::Failed->value, - failures: [[ - 'code' => 'findings.lifecycle.backfill.failed', - 'reason_code' => $reasonCode, - 'message' => $message !== '' ? $message : 'Findings lifecycle backfill failed.', - ]], - ); - - $runbookService->maybeFinalize($operationRun); - - throw $e; - } finally { - $lock->release(); - } - } - - private function backfillLifecycleFields(Finding $finding, CarbonImmutable $backfillStartedAt): void - { - $createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at) : $backfillStartedAt; - - if ($finding->first_seen_at === null) { - $finding->first_seen_at = $createdAt; - } - - if ($finding->last_seen_at === null) { - $finding->last_seen_at = $createdAt; - } - - if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) { - $lastSeen = CarbonImmutable::instance($finding->last_seen_at); - $firstSeen = CarbonImmutable::instance($finding->first_seen_at); - - if ($lastSeen->lessThan($firstSeen)) { - $finding->last_seen_at = $firstSeen; - } - } - - $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; - - if ($timesSeen < 1) { - $finding->times_seen = 1; - } - } - - private function backfillLegacyAcknowledgedStatus(Finding $finding): void - { - if ($finding->status !== Finding::STATUS_ACKNOWLEDGED) { - return; - } - - $finding->status = Finding::STATUS_TRIAGED; - - if ($finding->triaged_at === null) { - if ($finding->acknowledged_at !== null) { - $finding->triaged_at = CarbonImmutable::instance($finding->acknowledged_at); - } elseif ($finding->created_at !== null) { - $finding->triaged_at = CarbonImmutable::instance($finding->created_at); - } - } - } - - private function backfillSlaFields( - Finding $finding, - Tenant $tenant, - FindingSlaPolicy $slaPolicy, - CarbonImmutable $backfillStartedAt, - ): void { - if (! Finding::isOpenStatus((string) $finding->status)) { - return; - } - - if ($finding->sla_days === null) { - $finding->sla_days = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant); - } - - if ($finding->due_at === null) { - $finding->due_at = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $backfillStartedAt); - } - } - - private function backfillDriftRecurrenceKey(Finding $finding): void - { - if ($finding->finding_type !== Finding::FINDING_TYPE_DRIFT) { - return; - } - - if ($finding->recurrence_key !== null && trim((string) $finding->recurrence_key) !== '') { - return; - } - - $tenantId = (int) ($finding->tenant_id ?? 0); - $scopeKey = (string) ($finding->scope_key ?? ''); - $subjectType = (string) ($finding->subject_type ?? ''); - $subjectExternalId = (string) ($finding->subject_external_id ?? ''); - - if ($tenantId <= 0 || $scopeKey === '' || $subjectType === '' || $subjectExternalId === '') { - return; - } - - $evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : []; - $kind = Arr::get($evidence, 'summary.kind'); - $changeType = Arr::get($evidence, 'change_type'); - - $kind = is_string($kind) ? $kind : ''; - $changeType = is_string($changeType) ? $changeType : ''; - - if ($kind === '') { - return; - } - - $dimension = $this->recurrenceDimension($kind, $changeType); - - $finding->recurrence_key = hash('sha256', sprintf( - 'drift:%d:%s:%s:%s:%s', - $tenantId, - $scopeKey, - $subjectType, - $subjectExternalId, - $dimension, - )); - } - - private function recurrenceDimension(string $kind, string $changeType): string - { - $kind = strtolower(trim($kind)); - $changeType = strtolower(trim($changeType)); - - return match ($kind) { - 'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType !== '' ? $changeType : 'modified'), - default => $kind, - }; - } - - private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $backfillStartedAt): int - { - $duplicateKeys = Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->where('finding_type', Finding::FINDING_TYPE_DRIFT) - ->whereNotNull('recurrence_key') - ->select(['recurrence_key']) - ->groupBy('recurrence_key') - ->havingRaw('COUNT(*) > 1') - ->pluck('recurrence_key') - ->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '') - ->values(); - - if ($duplicateKeys->isEmpty()) { - return 0; - } - - $consolidated = 0; - - foreach ($duplicateKeys as $recurrenceKey) { - if (! is_string($recurrenceKey) || $recurrenceKey === '') { - continue; - } - - $findings = Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->where('finding_type', Finding::FINDING_TYPE_DRIFT) - ->where('recurrence_key', $recurrenceKey) - ->orderBy('id') - ->get(); - - $canonical = $this->chooseCanonicalDriftFinding($findings, $recurrenceKey); - - foreach ($findings as $finding) { - if (! $finding instanceof Finding) { - continue; - } - - if ($canonical instanceof Finding && (int) $finding->getKey() === (int) $canonical->getKey()) { - continue; - } - - $finding->forceFill([ - 'status' => Finding::STATUS_CLOSED, - 'resolved_at' => null, - 'resolved_reason' => null, - 'closed_at' => $backfillStartedAt, - 'closed_reason' => Finding::CLOSE_REASON_DUPLICATE, - 'closed_by_user_id' => null, - 'recurrence_key' => null, - ])->save(); - - $consolidated++; - } - } - - return $consolidated; - } - - /** - * @param Collection $findings - */ - private function chooseCanonicalDriftFinding(Collection $findings, string $recurrenceKey): ?Finding - { - if ($findings->isEmpty()) { - return null; - } - - $openCandidates = $findings->filter(static fn (Finding $finding): bool => Finding::isOpenStatus((string) $finding->status)); - - $candidates = $openCandidates->isNotEmpty() ? $openCandidates : $findings; - - $alreadyCanonical = $candidates->first(static fn (Finding $finding): bool => (string) $finding->fingerprint === $recurrenceKey); - - if ($alreadyCanonical instanceof Finding) { - return $alreadyCanonical; - } - - /** @var Finding $sorted */ - $sorted = $candidates - ->sortByDesc(function (Finding $finding): array { - $lastSeen = $finding->last_seen_at !== null ? CarbonImmutable::instance($finding->last_seen_at)->getTimestamp() : 0; - $createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at)->getTimestamp() : 0; - - return [ - max($lastSeen, $createdAt), - (int) $finding->getKey(), - ]; - }) - ->first(); - - return $sorted; - } -} diff --git a/apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php b/apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php deleted file mode 100644 index 09aae1a1..00000000 --- a/apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php +++ /dev/null @@ -1,378 +0,0 @@ -find($this->tenantId); - - if (! $tenant instanceof Tenant) { - return; - } - - if ((int) $tenant->workspace_id !== $this->workspaceId) { - return; - } - - $run = OperationRun::query()->find($this->operationRunId); - - if (! $run instanceof OperationRun) { - return; - } - - if ((int) $run->workspace_id !== $this->workspaceId) { - return; - } - - if ($run->tenant_id !== null) { - return; - } - - if ($run->status === 'queued') { - $operationRunService->updateRun($run, status: 'running'); - } - - $lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900); - - if (! $lock->get()) { - $operationRunService->appendFailures($run, [ - [ - 'code' => 'findings.lifecycle.backfill.lock_busy', - 'message' => sprintf('Tenant %d is already running a findings lifecycle backfill.', $this->tenantId), - ], - ]); - - $operationRunService->incrementSummaryCounts($run, [ - 'failed' => 1, - 'processed' => 1, - ]); - - $operationRunService->maybeCompleteBulkRun($run); - $runbookService->maybeFinalize($run); - - return; - } - - try { - $backfillStartedAt = $run->started_at !== null - ? CarbonImmutable::instance($run->started_at) - : CarbonImmutable::now('UTC'); - - Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->orderBy('id') - ->chunkById(200, function (Collection $findings) use ($tenant, $slaPolicy, $operationRunService, $run, $backfillStartedAt): void { - $updated = 0; - $skipped = 0; - - foreach ($findings as $finding) { - if (! $finding instanceof Finding) { - continue; - } - - $originalAttributes = $finding->getAttributes(); - - $this->backfillLifecycleFields($finding, $backfillStartedAt); - $this->backfillLegacyAcknowledgedStatus($finding); - $this->backfillSlaFields($finding, $tenant, $slaPolicy, $backfillStartedAt); - $this->backfillDriftRecurrenceKey($finding); - - if ($finding->isDirty()) { - $finding->save(); - $updated++; - } else { - $finding->setRawAttributes($originalAttributes, sync: true); - $skipped++; - } - } - - if ($updated > 0 || $skipped > 0) { - $operationRunService->incrementSummaryCounts($run, [ - 'updated' => $updated, - 'skipped' => $skipped, - ]); - } - }); - - $consolidatedDuplicates = $this->consolidateDriftDuplicates($tenant, $backfillStartedAt); - - if ($consolidatedDuplicates > 0) { - $operationRunService->incrementSummaryCounts($run, [ - 'updated' => $consolidatedDuplicates, - ]); - } - - $operationRunService->incrementSummaryCounts($run, [ - 'processed' => 1, - ]); - - $operationRunService->maybeCompleteBulkRun($run); - $runbookService->maybeFinalize($run); - } catch (Throwable $e) { - $message = RunFailureSanitizer::sanitizeMessage($e->getMessage()); - $reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage()); - - $operationRunService->appendFailures($run, [[ - 'code' => 'findings.lifecycle.backfill.failed', - 'reason_code' => $reasonCode, - 'message' => $message !== '' ? $message : sprintf('Tenant %d findings lifecycle backfill failed.', $this->tenantId), - ]]); - - $operationRunService->incrementSummaryCounts($run, [ - 'failed' => 1, - 'processed' => 1, - ]); - - $operationRunService->maybeCompleteBulkRun($run); - $runbookService->maybeFinalize($run); - - throw $e; - } finally { - $lock->release(); - } - } - - private function backfillLifecycleFields(Finding $finding, CarbonImmutable $backfillStartedAt): void - { - $createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at) : $backfillStartedAt; - - if ($finding->first_seen_at === null) { - $finding->first_seen_at = $createdAt; - } - - if ($finding->last_seen_at === null) { - $finding->last_seen_at = $createdAt; - } - - if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) { - $lastSeen = CarbonImmutable::instance($finding->last_seen_at); - $firstSeen = CarbonImmutable::instance($finding->first_seen_at); - - if ($lastSeen->lessThan($firstSeen)) { - $finding->last_seen_at = $firstSeen; - } - } - - $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; - - if ($timesSeen < 1) { - $finding->times_seen = 1; - } - } - - private function backfillLegacyAcknowledgedStatus(Finding $finding): void - { - if ($finding->status !== Finding::STATUS_ACKNOWLEDGED) { - return; - } - - $finding->status = Finding::STATUS_TRIAGED; - - if ($finding->triaged_at === null) { - if ($finding->acknowledged_at !== null) { - $finding->triaged_at = CarbonImmutable::instance($finding->acknowledged_at); - } elseif ($finding->created_at !== null) { - $finding->triaged_at = CarbonImmutable::instance($finding->created_at); - } - } - } - - private function backfillSlaFields( - Finding $finding, - Tenant $tenant, - FindingSlaPolicy $slaPolicy, - CarbonImmutable $backfillStartedAt, - ): void { - if (! Finding::isOpenStatus((string) $finding->status)) { - return; - } - - if ($finding->sla_days === null) { - $finding->sla_days = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant); - } - - if ($finding->due_at === null) { - $finding->due_at = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $backfillStartedAt); - } - } - - private function backfillDriftRecurrenceKey(Finding $finding): void - { - if ($finding->finding_type !== Finding::FINDING_TYPE_DRIFT) { - return; - } - - if ($finding->recurrence_key !== null && trim((string) $finding->recurrence_key) !== '') { - return; - } - - $tenantId = (int) ($finding->tenant_id ?? 0); - $scopeKey = (string) ($finding->scope_key ?? ''); - $subjectType = (string) ($finding->subject_type ?? ''); - $subjectExternalId = (string) ($finding->subject_external_id ?? ''); - - if ($tenantId <= 0 || $scopeKey === '' || $subjectType === '' || $subjectExternalId === '') { - return; - } - - $evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : []; - $kind = Arr::get($evidence, 'summary.kind'); - $changeType = Arr::get($evidence, 'change_type'); - - $kind = is_string($kind) ? $kind : ''; - $changeType = is_string($changeType) ? $changeType : ''; - - if ($kind === '') { - return; - } - - $dimension = $this->recurrenceDimension($kind, $changeType); - - $finding->recurrence_key = hash('sha256', sprintf( - 'drift:%d:%s:%s:%s:%s', - $tenantId, - $scopeKey, - $subjectType, - $subjectExternalId, - $dimension, - )); - } - - private function recurrenceDimension(string $kind, string $changeType): string - { - $kind = strtolower(trim($kind)); - $changeType = strtolower(trim($changeType)); - - return match ($kind) { - 'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType !== '' ? $changeType : 'modified'), - default => $kind, - }; - } - - private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $backfillStartedAt): int - { - $duplicateKeys = Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->where('finding_type', Finding::FINDING_TYPE_DRIFT) - ->whereNotNull('recurrence_key') - ->select(['recurrence_key']) - ->groupBy('recurrence_key') - ->havingRaw('COUNT(*) > 1') - ->pluck('recurrence_key') - ->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '') - ->values(); - - if ($duplicateKeys->isEmpty()) { - return 0; - } - - $consolidated = 0; - - foreach ($duplicateKeys as $recurrenceKey) { - if (! is_string($recurrenceKey) || $recurrenceKey === '') { - continue; - } - - $findings = Finding::query() - ->where('tenant_id', $tenant->getKey()) - ->where('finding_type', Finding::FINDING_TYPE_DRIFT) - ->where('recurrence_key', $recurrenceKey) - ->orderBy('id') - ->get(); - - $canonical = $this->chooseCanonicalDriftFinding($findings, $recurrenceKey); - - foreach ($findings as $finding) { - if (! $finding instanceof Finding) { - continue; - } - - if ($canonical instanceof Finding && (int) $finding->getKey() === (int) $canonical->getKey()) { - continue; - } - - $finding->forceFill([ - 'status' => Finding::STATUS_CLOSED, - 'resolved_at' => null, - 'resolved_reason' => null, - 'closed_at' => $backfillStartedAt, - 'closed_reason' => Finding::CLOSE_REASON_DUPLICATE, - 'closed_by_user_id' => null, - 'recurrence_key' => null, - ])->save(); - - $consolidated++; - } - } - - return $consolidated; - } - - /** - * @param Collection $findings - */ - private function chooseCanonicalDriftFinding(Collection $findings, string $recurrenceKey): ?Finding - { - if ($findings->isEmpty()) { - return null; - } - - $openCandidates = $findings->filter(static fn (Finding $finding): bool => Finding::isOpenStatus((string) $finding->status)); - - $candidates = $openCandidates->isNotEmpty() ? $openCandidates : $findings; - - $alreadyCanonical = $candidates->first(static fn (Finding $finding): bool => (string) $finding->fingerprint === $recurrenceKey); - - if ($alreadyCanonical instanceof Finding) { - return $alreadyCanonical; - } - - /** @var Finding $sorted */ - $sorted = $candidates - ->sortByDesc(function (Finding $finding): array { - $lastSeen = $finding->last_seen_at !== null ? CarbonImmutable::instance($finding->last_seen_at)->getTimestamp() : 0; - $createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at)->getTimestamp() : 0; - - return [ - max($lastSeen, $createdAt), - (int) $finding->getKey(), - ]; - }) - ->first(); - - return $sorted; - } -} diff --git a/apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php b/apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php deleted file mode 100644 index f7e93a97..00000000 --- a/apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php +++ /dev/null @@ -1,95 +0,0 @@ -find($this->operationRunId); - - if (! $run instanceof OperationRun) { - return; - } - - if ((int) $run->workspace_id !== $this->workspaceId) { - return; - } - - if ($run->tenant_id !== null) { - return; - } - - $tenantIds = $allowedTenantUniverse - ->query() - ->where('workspace_id', $this->workspaceId) - ->orderBy('id') - ->pluck('id') - ->map(static fn (mixed $id): int => (int) $id) - ->all(); - - $tenantCount = count($tenantIds); - - $operationRunService->updateRun( - $run, - status: OperationRunStatus::Running->value, - outcome: OperationRunOutcome::Pending->value, - summaryCounts: [ - 'tenants' => $tenantCount, - 'total' => $tenantCount, - 'processed' => 0, - 'updated' => 0, - 'skipped' => 0, - 'failed' => 0, - ], - ); - - if ($tenantCount === 0) { - $operationRunService->updateRun( - $run, - status: OperationRunStatus::Completed->value, - outcome: OperationRunOutcome::Succeeded->value, - ); - - $runbookService->maybeFinalize($run); - - return; - } - - foreach ($tenantIds as $tenantId) { - if ($tenantId <= 0) { - continue; - } - - BackfillFindingLifecycleTenantIntoWorkspaceRunJob::dispatch( - operationRunId: (int) $run->getKey(), - workspaceId: $this->workspaceId, - tenantId: $tenantId, - ); - } - } -} diff --git a/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php b/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php deleted file mode 100644 index 8302ff85..00000000 --- a/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php +++ /dev/null @@ -1,739 +0,0 @@ -computePreflight($scope); - - $this->auditSafely( - action: 'platform.ops.runbooks.preflight', - scope: $scope, - operationRunId: null, - initiator: null, - context: [ - 'preflight' => $result, - ], - ); - - return $result; - } - - public function start( - FindingsLifecycleBackfillScope $scope, - User|PlatformUser|null $initiator, - ?RunbookReason $reason, - string $source, - ): OperationRun { - $source = trim($source); - - if ($source === '') { - throw ValidationException::withMessages([ - 'source' => 'A run source is required.', - ]); - } - - $isBreakGlassActive = $this->breakGlassSession->isActive(); - - if ($scope->isAllTenants() || $isBreakGlassActive) { - if (! $reason instanceof RunbookReason) { - throw ValidationException::withMessages([ - 'reason' => 'A reason is required for this run.', - ]); - } - } - - $preflight = $this->computePreflight($scope); - - if (($preflight['affected_count'] ?? 0) <= 0) { - throw ValidationException::withMessages([ - 'preflight.affected_count' => 'Nothing to do for this scope.', - ]); - } - - $workspace = null; - $tenant = null; - - if ($scope->isSingleTenant()) { - $tenant = Tenant::query()->whereKey((int) $scope->tenantId)->firstOrFail(); - $this->allowedTenantUniverse->ensureAllowed($tenant); - - $workspace = $tenant->workspace; - } else { - $platformTenant = $this->platformTenant(); - $workspace = $platformTenant->workspace; - } - - if (! $workspace instanceof Workspace) { - throw new \RuntimeException('Platform tenant is missing its workspace.'); - } - - $decision = $this->operationalControls->evaluate(self::RUNBOOK_KEY, $workspace); - - if ($decision->isPaused()) { - $this->auditBlockedStart( - decision: $decision, - scope: $scope, - workspace: $workspace, - tenant: $tenant, - initiator: $initiator, - source: $source, - ); - - throw OperationalControlBlockedException::forDecision( - decision: $decision, - actionLabel: OperationCatalog::label(self::RUNBOOK_KEY), - ); - } - - if ($scope->isAllTenants()) { - $lockKey = sprintf('tenantpilot:runbooks:%s:workspace:%d', self::RUNBOOK_KEY, (int) $workspace->getKey()); - $lock = Cache::lock($lockKey, 900); - - if (! $lock->get()) { - throw ValidationException::withMessages([ - 'scope' => 'Another run is already in progress for this scope.', - ]); - } - - try { - return $this->startAllTenants( - workspace: $workspace, - initiator: $initiator, - reason: $reason, - preflight: $preflight, - source: $source, - isBreakGlassActive: $isBreakGlassActive, - ); - } finally { - $lock->release(); - } - } - - return $this->startSingleTenant( - tenant: $tenant, - initiator: $initiator, - reason: $reason, - preflight: $preflight, - source: $source, - isBreakGlassActive: $isBreakGlassActive, - ); - } - - public function maybeFinalize(OperationRun $run): void - { - $run->refresh(); - - if ($run->status !== OperationRunStatus::Completed->value) { - return; - } - - $context = is_array($run->context) ? $run->context : []; - - if ((string) data_get($context, 'runbook.key') !== self::RUNBOOK_KEY) { - return; - } - - $lockKey = sprintf('tenantpilot:runbooks:%s:finalize:%d', self::RUNBOOK_KEY, (int) $run->getKey()); - $lock = Cache::lock($lockKey, 86400); - - if (! $lock->get()) { - return; - } - - try { - $this->auditSafely( - action: $run->outcome === OperationRunOutcome::Failed->value - ? 'platform.ops.runbooks.failed' - : 'platform.ops.runbooks.completed', - scope: $this->scopeFromRunContext($context), - operationRunId: (int) $run->getKey(), - context: [ - 'status' => (string) $run->status, - 'outcome' => (string) $run->outcome, - 'is_break_glass' => (bool) data_get($context, 'platform_initiator.is_break_glass', false), - 'reason_code' => data_get($context, 'reason.reason_code'), - 'reason_text' => data_get($context, 'reason.reason_text'), - ], - ); - - $this->notifyInitiatorSafely($run); - - if ($run->outcome === OperationRunOutcome::Failed->value) { - $this->dispatchFailureAlertSafely($run); - } - } finally { - $lock->release(); - } - } - - /** - * @return array{affected_count: int, total_count: int, estimated_tenants?: int|null} - */ - private function computePreflight(FindingsLifecycleBackfillScope $scope): array - { - if ($scope->isSingleTenant()) { - $tenant = Tenant::query()->whereKey((int) $scope->tenantId)->firstOrFail(); - $this->allowedTenantUniverse->ensureAllowed($tenant); - - return $this->computeTenantPreflight($tenant); - } - - $platformTenant = $this->platformTenant(); - $workspaceId = (int) ($platformTenant->workspace_id ?? 0); - - $tenants = $this->allowedTenantUniverse - ->query() - ->where('workspace_id', $workspaceId) - ->orderBy('id') - ->get(); - - $affected = 0; - $total = 0; - - foreach ($tenants as $tenant) { - if (! $tenant instanceof Tenant) { - continue; - } - - $counts = $this->computeTenantPreflight($tenant); - - $affected += (int) ($counts['affected_count'] ?? 0); - $total += (int) ($counts['total_count'] ?? 0); - } - - return [ - 'affected_count' => $affected, - 'total_count' => $total, - 'estimated_tenants' => $tenants->count(), - ]; - } - - /** - * @return array{affected_count: int, total_count: int} - */ - private function computeTenantPreflight(Tenant $tenant): array - { - $query = Finding::query()->where('tenant_id', (int) $tenant->getKey()); - - $total = (int) (clone $query)->count(); - - $affected = 0; - - (clone $query) - ->orderBy('id') - ->chunkById(500, function ($findings) use (&$affected): void { - foreach ($findings as $finding) { - if (! $finding instanceof Finding) { - continue; - } - - if ($this->findingNeedsBackfill($finding)) { - $affected++; - } - } - }); - - $affected += $this->countDriftDuplicateConsolidations($tenant); - - return [ - 'affected_count' => $affected, - 'total_count' => $total, - ]; - } - - private function findingNeedsBackfill(Finding $finding): bool - { - if ($finding->first_seen_at === null || $finding->last_seen_at === null) { - return true; - } - - if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) { - if ($finding->last_seen_at->lt($finding->first_seen_at)) { - return true; - } - } - - $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; - - if ($timesSeen < 1) { - return true; - } - - if ($finding->status === Finding::STATUS_ACKNOWLEDGED) { - return true; - } - - if (Finding::isOpenStatus((string) $finding->status)) { - if ($finding->sla_days === null || $finding->due_at === null) { - return true; - } - } - - if ($finding->finding_type === Finding::FINDING_TYPE_DRIFT) { - $recurrenceKey = $finding->recurrence_key !== null ? trim((string) $finding->recurrence_key) : ''; - - if ($recurrenceKey === '') { - $scopeKey = trim((string) ($finding->scope_key ?? '')); - $subjectType = trim((string) ($finding->subject_type ?? '')); - $subjectExternalId = trim((string) ($finding->subject_external_id ?? '')); - - if ($scopeKey !== '' && $subjectType !== '' && $subjectExternalId !== '') { - $evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : []; - $kind = data_get($evidence, 'summary.kind'); - - if (is_string($kind) && trim($kind) !== '') { - return true; - } - } - } - } - - return false; - } - - private function countDriftDuplicateConsolidations(Tenant $tenant): int - { - $rows = Finding::query() - ->where('tenant_id', (int) $tenant->getKey()) - ->where('finding_type', Finding::FINDING_TYPE_DRIFT) - ->whereNotNull('recurrence_key') - ->select(['recurrence_key', DB::raw('COUNT(*) as count')]) - ->groupBy('recurrence_key') - ->havingRaw('COUNT(*) > 1') - ->get(); - - $duplicates = 0; - - foreach ($rows as $row) { - $count = is_numeric($row->count ?? null) ? (int) $row->count : 0; - - if ($count > 1) { - $duplicates += ($count - 1); - } - } - - return $duplicates; - } - - private function startAllTenants( - Workspace $workspace, - User|PlatformUser|null $initiator, - ?RunbookReason $reason, - array $preflight, - string $source, - bool $isBreakGlassActive, - ): OperationRun { - $run = $this->operationRunService->ensureWorkspaceRunWithIdentity( - workspace: $workspace, - type: self::RUNBOOK_KEY, - identityInputs: [ - 'runbook' => self::RUNBOOK_KEY, - 'scope' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, - ], - context: $this->buildRunContext( - workspaceId: (int) $workspace->getKey(), - scope: FindingsLifecycleBackfillScope::allTenants(), - initiator: $initiator, - reason: $reason, - preflight: $preflight, - source: $source, - isBreakGlassActive: $isBreakGlassActive, - ), - initiator: $initiator instanceof User ? $initiator : null, - ); - - if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) { - $run->update(['initiator_name' => $initiator->name ?: $initiator->email]); - $run->refresh(); - } - - $this->auditSafely( - action: 'platform.ops.runbooks.start', - scope: FindingsLifecycleBackfillScope::allTenants(), - operationRunId: (int) $run->getKey(), - initiator: $initiator, - context: [ - 'preflight' => $preflight, - 'is_break_glass' => $isBreakGlassActive, - ] + ($reason instanceof RunbookReason ? $reason->toArray() : []), - ); - - if (! $run->wasRecentlyCreated) { - return $run; - } - - $this->operationRunService->dispatchOrFail($run, function () use ($run, $workspace): void { - BackfillFindingLifecycleWorkspaceJob::dispatch( - operationRunId: (int) $run->getKey(), - workspaceId: (int) $workspace->getKey(), - ); - }); - - return $run; - } - - private function startSingleTenant( - ?Tenant $tenant, - User|PlatformUser|null $initiator, - ?RunbookReason $reason, - array $preflight, - string $source, - bool $isBreakGlassActive, - ): OperationRun { - if (! $tenant instanceof Tenant) { - throw new \RuntimeException('Target tenant is required for single-tenant runs.'); - } - - $run = $this->operationRunService->ensureRunWithIdentity( - tenant: $tenant, - type: self::RUNBOOK_KEY, - identityInputs: [ - 'tenant_id' => (int) $tenant->getKey(), - 'trigger' => 'backfill', - ], - context: $this->buildRunContext( - workspaceId: (int) $tenant->workspace_id, - scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), - initiator: $initiator, - reason: $reason, - preflight: $preflight, - source: $source, - isBreakGlassActive: $isBreakGlassActive, - ), - initiator: $initiator instanceof User ? $initiator : null, - ); - - if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) { - $run->update(['initiator_name' => $initiator->name ?: $initiator->email]); - $run->refresh(); - } - - $this->auditSafely( - action: 'platform.ops.runbooks.start', - scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), - operationRunId: (int) $run->getKey(), - initiator: $initiator, - context: [ - 'preflight' => $preflight, - 'is_break_glass' => $isBreakGlassActive, - ] + ($reason instanceof RunbookReason ? $reason->toArray() : []), - ); - - if (! $run->wasRecentlyCreated) { - return $run; - } - - $this->operationRunService->dispatchOrFail($run, function () use ($tenant): void { - BackfillFindingLifecycleJob::dispatch( - tenantId: (int) $tenant->getKey(), - workspaceId: (int) $tenant->workspace_id, - initiatorUserId: null, - ); - }); - - return $run; - } - - private function platformTenant(): Tenant - { - $tenant = Tenant::query()->where('external_id', 'platform')->first(); - - if (! $tenant instanceof Tenant) { - throw new \RuntimeException('Platform tenant is missing.'); - } - - return $tenant; - } - - /** - * @return array - */ - private function buildRunContext( - int $workspaceId, - FindingsLifecycleBackfillScope $scope, - User|PlatformUser|null $initiator, - ?RunbookReason $reason, - array $preflight, - string $source, - bool $isBreakGlassActive, - ): array { - $context = [ - 'workspace_id' => $workspaceId, - 'runbook' => [ - 'key' => self::RUNBOOK_KEY, - 'scope' => $scope->mode, - 'target_tenant_id' => $scope->tenantId, - 'source' => $source, - ], - 'preflight' => [ - 'affected_count' => (int) ($preflight['affected_count'] ?? 0), - 'total_count' => (int) ($preflight['total_count'] ?? 0), - 'estimated_tenants' => $preflight['estimated_tenants'] ?? null, - ], - ]; - - if ($reason instanceof RunbookReason) { - $context['reason'] = $reason->toArray(); - } - - if ($initiator instanceof PlatformUser) { - $context['platform_initiator'] = [ - 'platform_user_id' => (int) $initiator->getKey(), - 'email' => (string) $initiator->email, - 'name' => (string) $initiator->name, - 'is_break_glass' => $isBreakGlassActive, - ]; - } elseif ($initiator instanceof User) { - $context['tenant_initiator'] = [ - 'user_id' => (int) $initiator->getKey(), - 'email' => (string) $initiator->email, - 'name' => (string) $initiator->name, - ]; - } - - return $context; - } - - private function scopeFromRunContext(array $context): FindingsLifecycleBackfillScope - { - $scope = data_get($context, 'runbook.scope'); - $tenantId = data_get($context, 'runbook.target_tenant_id'); - - if ($scope === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT && is_numeric($tenantId)) { - return FindingsLifecycleBackfillScope::singleTenant((int) $tenantId); - } - - return FindingsLifecycleBackfillScope::allTenants(); - } - - /** - * @param array $context - */ - private function auditSafely( - string $action, - FindingsLifecycleBackfillScope $scope, - ?int $operationRunId, - User|PlatformUser|null $initiator, - array $context = [], - ): void { - try { - $metadata = [ - 'runbook_key' => self::RUNBOOK_KEY, - 'scope' => $scope->mode, - 'target_tenant_id' => $scope->tenantId, - 'operation_run_id' => $operationRunId, - 'ip' => request()->ip(), - 'user_agent' => request()->userAgent(), - ]; - - if ($initiator instanceof User && $scope->isSingleTenant()) { - $tenant = Tenant::query()->whereKey((int) $scope->tenantId)->first(); - - if ($tenant instanceof Tenant) { - $this->auditLogger->log( - tenant: $tenant, - action: $action, - context: [ - 'metadata' => array_filter($metadata, static fn (mixed $value): bool => $value !== null), - ] + $context, - actorId: (int) $initiator->getKey(), - actorEmail: (string) $initiator->email, - actorName: (string) $initiator->name, - status: 'success', - resourceType: 'operation_run', - resourceId: $operationRunId !== null ? (string) $operationRunId : null, - ); - - return; - } - } - - $platformTenant = $this->platformTenant(); - $platformActor = $initiator instanceof PlatformUser - ? $initiator - : auth('platform')->user(); - - $actorId = $platformActor instanceof PlatformUser ? (int) $platformActor->getKey() : null; - $actorEmail = $platformActor instanceof PlatformUser ? (string) $platformActor->email : null; - $actorName = $platformActor instanceof PlatformUser ? (string) $platformActor->name : null; - - $this->auditLogger->log( - tenant: $platformTenant, - action: $action, - context: [ - 'metadata' => array_filter($metadata, static fn (mixed $value): bool => $value !== null), - ] + $context, - actorId: $actorId, - actorEmail: $actorEmail, - actorName: $actorName, - status: 'success', - resourceType: 'operation_run', - resourceId: $operationRunId !== null ? (string) $operationRunId : null, - ); - } catch (Throwable) { - // Audit is fail-safe (must not crash runbooks). - } - } - - private function auditBlockedStart( - \App\Support\OperationalControls\OperationalControlDecision $decision, - FindingsLifecycleBackfillScope $scope, - Workspace $workspace, - ?Tenant $tenant, - User|PlatformUser|null $initiator, - string $source, - ): void { - try { - $metadata = array_filter([ - 'control_key' => $decision->controlKey, - 'scope_type' => $decision->matchedScopeType, - 'workspace_id' => (int) $workspace->getKey(), - 'reason_text' => $decision->reasonText, - 'expires_at' => $decision->expiresAt?->toIso8601String(), - 'actor_id' => $initiator instanceof User || $initiator instanceof PlatformUser ? (int) $initiator->getKey() : null, - 'requested_scope' => $scope->mode, - 'target_tenant_id' => $scope->tenantId, - 'source' => $source, - 'runbook_key' => self::RUNBOOK_KEY, - ], static fn (mixed $value): bool => $value !== null && $value !== ''); - - $summary = sprintf('%s blocked by operational control', OperationCatalog::label(self::RUNBOOK_KEY)); - - if ($scope->isAllTenants()) { - $this->auditRecorder->record( - action: AuditActionId::OperationalControlExecutionBlocked, - context: ['metadata' => $metadata], - actor: $initiator instanceof PlatformUser ? AuditActorSnapshot::platform($initiator) : null, - target: new AuditTargetSnapshot( - type: 'operational_control', - id: $decision->sourceActivationId, - label: OperationCatalog::label(self::RUNBOOK_KEY), - ), - outcome: 'blocked', - summary: $summary, - ); - - return; - } - - if (! $tenant instanceof Tenant) { - return; - } - - $this->workspaceAuditLogger->log( - workspace: $workspace, - action: AuditActionId::OperationalControlExecutionBlocked, - context: ['metadata' => $metadata], - actor: $initiator, - status: 'blocked', - resourceType: 'operational_control', - resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null, - targetLabel: OperationCatalog::label(self::RUNBOOK_KEY), - summary: $summary, - tenant: $tenant, - ); - } catch (Throwable) { - // Audit is fail-safe (must not crash runbooks). - } - } - - private function notifyInitiatorSafely(OperationRun $run): void - { - try { - $platformUserId = data_get($run->context, 'platform_initiator.platform_user_id'); - - if (! is_numeric($platformUserId)) { - return; - } - - $platformUser = PlatformUser::query()->whereKey((int) $platformUserId)->first(); - - if (! $platformUser instanceof PlatformUser) { - return; - } - - $platformUser->notify(new OperationRunCompleted($run)); - } catch (Throwable) { - // Notifications must not crash the runbook. - } - } - - private function dispatchFailureAlertSafely(OperationRun $run): void - { - try { - $platformTenant = $this->platformTenant(); - $workspace = $platformTenant->workspace; - - if (! $workspace instanceof Workspace) { - return; - } - - $this->alertDispatchService->dispatchEvent($workspace, [ - 'tenant_id' => (int) $platformTenant->getKey(), - 'event_type' => 'operations.run.failed', - 'severity' => 'high', - 'title' => 'Operation failed: Findings lifecycle backfill', - 'body' => 'A findings lifecycle backfill run failed.', - 'metadata' => [ - 'operation_run_id' => (int) $run->getKey(), - 'operation_type' => $run->canonicalOperationType(), - 'scope' => (string) data_get($run->context, 'runbook.scope', ''), - 'view_run_url' => SystemOperationRunLinks::view($run), - ], - ]); - } catch (Throwable) { - // Alerts must not crash the runbook. - } - } -} diff --git a/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillScope.php b/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillScope.php deleted file mode 100644 index 3c913f70..00000000 --- a/apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillScope.php +++ /dev/null @@ -1,81 +0,0 @@ - 'Select a valid tenant.', - ]); - } - - return new self( - mode: self::MODE_SINGLE_TENANT, - tenantId: $tenantId, - ); - } - - /** - * @param array $data - */ - public static function fromArray(array $data): self - { - $mode = trim((string) ($data['mode'] ?? '')); - - if ($mode === '' || $mode === self::MODE_ALL_TENANTS) { - return self::allTenants(); - } - - if ($mode !== self::MODE_SINGLE_TENANT) { - throw ValidationException::withMessages([ - 'scope.mode' => 'Select a valid scope mode.', - ]); - } - - $tenantId = $data['tenant_id'] ?? null; - - if (! is_numeric($tenantId)) { - throw ValidationException::withMessages([ - 'scope.tenant_id' => 'Select a tenant.', - ]); - } - - return self::singleTenant((int) $tenantId); - } - - public function isAllTenants(): bool - { - return $this->mode === self::MODE_ALL_TENANTS; - } - - public function isSingleTenant(): bool - { - return $this->mode === self::MODE_SINGLE_TENANT; - } -} diff --git a/apps/platform/app/Services/SystemConsole/OperationRunTriageService.php b/apps/platform/app/Services/SystemConsole/OperationRunTriageService.php index caf32763..0150831e 100644 --- a/apps/platform/app/Services/SystemConsole/OperationRunTriageService.php +++ b/apps/platform/app/Services/SystemConsole/OperationRunTriageService.php @@ -17,7 +17,6 @@ final class OperationRunTriageService 'inventory.sync', 'policy.sync', 'directory.groups.sync', - 'findings.lifecycle.backfill', 'rbac.health_check', 'entra.admin_roles.scan', 'tenant.review_pack.generate', @@ -28,7 +27,6 @@ final class OperationRunTriageService 'inventory.sync', 'policy.sync', 'directory.groups.sync', - 'findings.lifecycle.backfill', 'rbac.health_check', 'entra.admin_roles.scan', 'tenant.review_pack.generate', diff --git a/apps/platform/app/Support/Auth/PlatformCapabilities.php b/apps/platform/app/Support/Auth/PlatformCapabilities.php index 7f3e359e..82b67dd3 100644 --- a/apps/platform/app/Support/Auth/PlatformCapabilities.php +++ b/apps/platform/app/Support/Auth/PlatformCapabilities.php @@ -30,8 +30,6 @@ class PlatformCapabilities public const RUNBOOKS_RUN = 'platform.runbooks.run'; - public const RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL = 'platform.runbooks.findings.lifecycle_backfill'; - public const OPS_CONTROLS_MANAGE = 'platform.ops.controls.manage'; /** diff --git a/apps/platform/app/Support/Livewire/TrustedState/TrustedStatePolicy.php b/apps/platform/app/Support/Livewire/TrustedState/TrustedStatePolicy.php index 469ce525..28e97fca 100644 --- a/apps/platform/app/Support/Livewire/TrustedState/TrustedStatePolicy.php +++ b/apps/platform/app/Support/Livewire/TrustedState/TrustedStatePolicy.php @@ -12,8 +12,6 @@ final class TrustedStatePolicy public const TENANT_REQUIRED_PERMISSIONS = 'tenant_required_permissions'; - public const SYSTEM_RUNBOOKS = 'system_runbooks'; - /** * @return array{ * name: string, @@ -329,92 +327,6 @@ public function firstSlice(): array 'scopedTenant', ], ], - self::SYSTEM_RUNBOOKS => [ - 'component_name' => 'System runbooks', - 'plane' => 'system_platform', - 'route_anchor' => null, - 'authority_sources' => [ - 'allowed_tenant_universe', - 'explicit_scoped_query', - ], - 'locked_identities' => [], - 'locked_identity_fields' => [], - 'mutable_selectors' => [ - 'findingsTenantId', - 'tenantId', - 'findingsScopeMode', - 'scopeMode', - ], - 'mutable_selector_fields' => [ - $this->field( - name: 'findingsTenantId', - stateClass: TrustedStateClass::Presentation, - phpType: '?int', - sourceOfTruth: 'allowed_tenant_universe', - usedForProtectedAction: true, - revalidationRequired: true, - implementationMarkers: [ - 'public ?int $findingsTenantId = null;', - 'resolveAllowedOrFail($this->findingsTenantId)', - ], - notes: 'Single-tenant runbook proposal only; it must be validated against the operator allowed-tenant universe.', - ), - $this->field( - name: 'tenantId', - stateClass: TrustedStateClass::Presentation, - phpType: '?int', - sourceOfTruth: 'allowed_tenant_universe', - usedForProtectedAction: false, - revalidationRequired: false, - implementationMarkers: [ - 'public ?int $tenantId = null;', - ], - notes: 'Mirrored display state for the last trusted preflight result.', - ), - $this->field( - name: 'findingsScopeMode', - stateClass: TrustedStateClass::Presentation, - phpType: 'string', - sourceOfTruth: 'presentation_only', - usedForProtectedAction: true, - revalidationRequired: true, - implementationMarkers: [ - 'public string $findingsScopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;', - 'trustedFindingsScopeFromState(', - ], - notes: 'Scope mode remains mutable UI state but protected actions re-normalize it into a trusted scope DTO.', - ), - $this->field( - name: 'scopeMode', - stateClass: TrustedStateClass::Presentation, - phpType: 'string', - sourceOfTruth: 'presentation_only', - usedForProtectedAction: false, - revalidationRequired: false, - implementationMarkers: [ - 'public string $scopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;', - ], - notes: 'Mirrored display state for the last trusted preflight result.', - ), - ], - 'server_derived_authority_fields' => [ - $this->field( - name: 'findingsScope', - stateClass: TrustedStateClass::ServerDerivedAuthority, - phpType: 'FindingsLifecycleBackfillScope', - sourceOfTruth: 'allowed_tenant_universe', - usedForProtectedAction: true, - revalidationRequired: true, - implementationMarkers: [ - 'trustedFindingsScopeFromFormData(', - 'trustedFindingsScopeFromState(', - 'resolveAllowedOrFail(', - ], - notes: 'Protected actions must convert mutable selector state into a trusted scope DTO via AllowedTenantUniverse.', - ), - ], - 'forbidden_public_authority_fields' => [], - ], ]; } diff --git a/apps/platform/app/Support/OperationCatalog.php b/apps/platform/app/Support/OperationCatalog.php index e1d649ca..dd5b31ba 100644 --- a/apps/platform/app/Support/OperationCatalog.php +++ b/apps/platform/app/Support/OperationCatalog.php @@ -278,7 +278,6 @@ private static function canonicalDefinitions(): array 'tenant.review.compose' => new CanonicalOperationType('tenant.review.compose', 'platform_foundation', 'tenant_review', 'Review composition', true, 60), 'tenant.evidence.snapshot.generate' => new CanonicalOperationType('tenant.evidence.snapshot.generate', 'platform_foundation', 'evidence_snapshot', 'Evidence snapshot generation', true, 120), 'rbac.health_check' => new CanonicalOperationType('rbac.health_check', 'intune', null, 'RBAC health check', false, 30), - 'findings.lifecycle.backfill' => new CanonicalOperationType('findings.lifecycle.backfill', 'platform_foundation', null, 'Findings lifecycle backfill', false, 300), ]; } @@ -331,7 +330,6 @@ private static function operationAliases(): array new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true), new OperationTypeAlias('tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate', 'canonical', true), new OperationTypeAlias('rbac.health_check', 'rbac.health_check', 'canonical', true), - new OperationTypeAlias('findings.lifecycle.backfill', 'findings.lifecycle.backfill', 'canonical', true), ]; } } diff --git a/apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php b/apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php index 97c86236..8f6df367 100644 --- a/apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php +++ b/apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php @@ -640,23 +640,18 @@ public static function spec195ResidualSurfaceInventory(): array 'discoveryState' => 'outside_primary_discovery', 'closureDecision' => 'separately_governed', 'reasonCategory' => 'workflow_specific_governance', - 'explicitReason' => 'Runbooks is a workflow utility hub with its own trusted-state, authorization, and confirmation semantics rather than a declaration-backed record or table surface.', + 'explicitReason' => 'Runbooks remains a system utility shell outside the declaration-backed record or table surface; it currently exposes no supported launch action after lifecycle-backfill removal.', 'evidence' => [ [ 'kind' => 'feature_livewire_test', - 'reference' => 'tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php', - 'proves' => 'The runbooks shell enforces preflight-first execution, typed confirmation, and capability-gated run behavior.', + 'reference' => 'tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php', + 'proves' => 'The runbooks shell stays accessible to authorized platform operators while exposing no findings lifecycle backfill launch action.', ], [ 'kind' => 'authorization_test', 'reference' => 'tests/Feature/System/Spec113/AuthorizationSemanticsTest.php', 'proves' => 'The system plane still returns 403 when runbook-view capabilities are missing.', ], - [ - 'kind' => 'guard_test', - 'reference' => 'tests/Feature/Guards/LivewireTrustedStateGuardTest.php', - 'proves' => 'Runbooks keeps its trusted-state policy under explicit guard coverage.', - ], ], 'followUpAction' => 'add_guard_only', 'mustRemainBaselineExempt' => false, diff --git a/apps/platform/database/seeders/PlatformUserSeeder.php b/apps/platform/database/seeders/PlatformUserSeeder.php index 3585c630..f81b9924 100644 --- a/apps/platform/database/seeders/PlatformUserSeeder.php +++ b/apps/platform/database/seeders/PlatformUserSeeder.php @@ -41,7 +41,6 @@ public function run(): void PlatformCapabilities::OPS_VIEW, PlatformCapabilities::RUNBOOKS_VIEW, PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, PlatformCapabilities::OPS_CONTROLS_MANAGE, ], 'is_active' => true, diff --git a/apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php b/apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php index 12e6d34a..37c6536f 100644 --- a/apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php +++ b/apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php @@ -1,13 +1,3 @@ -@php - $findingsLastRun = $this->findingsLastRun(); - $findingsLastRunStatusSpec = $findingsLastRun - ? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunStatus, (string) $findingsLastRun->status) - : null; - $findingsLastRunOutcomeSpec = $findingsLastRun && (string) $findingsLastRun->status === 'completed' - ? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunOutcome, (string) $findingsLastRun->outcome) - : null; -@endphp -
      @@ -17,7 +7,7 @@

      Operator warning

      - Runbooks can modify or assess customer data across tenants. Always run preflight first, and ensure you have the correct scope selected. + Runbooks can modify or assess customer data across tenants. When supported runbooks are available, verify scope and confirmation requirements before execution.

      @@ -25,100 +15,17 @@ - Rebuild Findings Lifecycle + No supported runbooks - Backfills legacy findings lifecycle fields, SLA due dates, and consolidates drift duplicate findings. + Supported platform runbooks will appear here when they are part of current product truth. - - - {{ $this->findingsScopeLabel() }} - - - -
      - @if ($findingsLastRun) -
      - Last run - - - {{ $findingsLastRun->created_at?->diffForHumans() ?? '—' }} - - - @if ($findingsLastRunStatusSpec) - - {{ $findingsLastRunStatusSpec->label }} - - @endif - - @if ($findingsLastRunOutcomeSpec) - - {{ $findingsLastRunOutcomeSpec->label }} - - @endif - - @if ($findingsLastRun->initiator_name) - - by {{ $findingsLastRun->initiator_name }} - - @endif -
      - @endif - - @if (is_array($this->findingsPreflight)) -
      - -
      -

      Affected

      -

      - {{ number_format((int) ($this->findingsPreflight['affected_count'] ?? 0)) }} -

      -
      -
      - - -
      -

      Total scanned

      -

      - {{ number_format((int) ($this->findingsPreflight['total_count'] ?? 0)) }} -

      -
      -
      - - -
      -

      Estimated tenants

      -

      - {{ is_numeric($this->findingsPreflight['estimated_tenants'] ?? null) ? number_format((int) $this->findingsPreflight['estimated_tenants']) : '—' }} -

      -
      -
      -
      - - @if ((int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0) -
      - - Nothing to do for the current scope. -
      - @endif - @else -
      - - Run Preflight to see how many findings would change for the selected scope. -
      - @endif +
      + + There are no operator-run repair runbooks exposed on this surface.
      -
      diff --git a/apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php b/apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php new file mode 100644 index 00000000..84301c55 --- /dev/null +++ b/apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php @@ -0,0 +1,20 @@ +not->toContain('tenantpilot:findings:backfill-lifecycle') + ->not->toContain('tenantpilot:run-deploy-runbooks'); + + expect((string) file_get_contents(base_path('routes/console.php'))) + ->not->toContain('tenantpilot:findings:backfill-lifecycle') + ->not->toContain('tenantpilot:run-deploy-runbooks'); +}); diff --git a/apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php b/apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php deleted file mode 100644 index f6cc1ab1..00000000 --- a/apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php +++ /dev/null @@ -1,31 +0,0 @@ -create(); - - $this->mock(FindingsLifecycleBackfillRunbookService::class, function ($mock) use ($run): void { - $mock->shouldReceive('start') - ->once() - ->withArgs(function ($scope, $initiator, $reason, $source): bool { - return $scope instanceof FindingsLifecycleBackfillScope - && $scope->isAllTenants() - && $initiator === null - && $reason instanceof RunbookReason - && $source === 'deploy_hook'; - }) - ->andReturn($run); - }); - - $this->artisan('tenantpilot:run-deploy-runbooks') - ->assertExitCode(0); -}); diff --git a/apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php b/apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php deleted file mode 100644 index de5815ba..00000000 --- a/apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php +++ /dev/null @@ -1,21 +0,0 @@ -actingAs($user); - Filament::setTenant($tenant, true); - - Livewire::test(ListFindings::class) - ->assertActionExists('backfill_lifecycle') - ->assertActionEnabled('backfill_lifecycle'); -}); diff --git a/apps/platform/tests/Feature/Findings/FindingBackfillTest.php b/apps/platform/tests/Feature/Findings/FindingBackfillTest.php deleted file mode 100644 index 24b28f0f..00000000 --- a/apps/platform/tests/Feature/Findings/FindingBackfillTest.php +++ /dev/null @@ -1,136 +0,0 @@ -for($tenant)->create([ - 'severity' => Finding::SEVERITY_MEDIUM, - 'status' => Finding::STATUS_ACKNOWLEDGED, - 'acknowledged_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'), - 'acknowledged_by_user_id' => (int) $user->getKey(), - 'first_seen_at' => null, - 'last_seen_at' => null, - 'times_seen' => null, - 'sla_days' => null, - 'due_at' => null, - 'triaged_at' => null, - 'created_at' => CarbonImmutable::parse('2026-02-10T00:00:00Z'), - 'updated_at' => CarbonImmutable::parse('2026-02-10T00:00:00Z'), - ]); - - BackfillFindingLifecycleJob::dispatchSync( - tenantId: (int) $tenant->getKey(), - workspaceId: (int) $tenant->workspace_id, - initiatorUserId: (int) $user->getKey(), - ); - - $finding->refresh(); - - expect($finding->status)->toBe(Finding::STATUS_TRIAGED) - ->and($finding->triaged_at?->toIso8601String())->toBe('2026-02-20T00:00:00+00:00') - ->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-10T00:00:00+00:00') - ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-10T00:00:00+00:00') - ->and($finding->times_seen)->toBe(1) - ->and($finding->sla_days)->toBe(14) - ->and($finding->due_at?->toIso8601String())->toBe('2026-03-10T10:00:00+00:00') - ->and($finding->acknowledged_at?->toIso8601String())->toBe('2026-02-20T00:00:00+00:00') - ->and((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey()); - - CarbonImmutable::setTestNow(); -}); - -it('computes drift recurrence keys and consolidates drift duplicates', function (): void { - CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z')); - - [$user, $tenant] = createUserWithTenant(role: 'manager'); - - $scopeKey = hash('sha256', 'scope-drift-backfill-duplicate'); - - $evidence = [ - 'change_type' => 'modified', - 'summary' => [ - 'kind' => 'policy_snapshot', - 'changed_fields' => ['snapshot_hash'], - ], - 'baseline' => ['policy_id' => 'policy-dupe'], - 'current' => ['policy_id' => 'policy-dupe'], - ]; - - $open = Finding::factory()->for($tenant)->create([ - 'finding_type' => Finding::FINDING_TYPE_DRIFT, - 'scope_key' => $scopeKey, - 'subject_type' => 'policy', - 'subject_external_id' => 'policy-dupe', - 'status' => Finding::STATUS_NEW, - 'recurrence_key' => null, - 'evidence_jsonb' => $evidence, - 'first_seen_at' => null, - 'last_seen_at' => null, - 'times_seen' => null, - 'sla_days' => null, - 'due_at' => null, - 'created_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'), - 'updated_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'), - ]); - - $duplicate = Finding::factory()->for($tenant)->create([ - 'finding_type' => Finding::FINDING_TYPE_DRIFT, - 'scope_key' => $scopeKey, - 'subject_type' => 'policy', - 'subject_external_id' => 'policy-dupe', - 'status' => Finding::STATUS_RESOLVED, - 'resolved_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'), - 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED, - 'recurrence_key' => null, - 'evidence_jsonb' => $evidence, - 'first_seen_at' => null, - 'last_seen_at' => null, - 'times_seen' => null, - 'created_at' => CarbonImmutable::parse('2026-02-18T00:00:00Z'), - 'updated_at' => CarbonImmutable::parse('2026-02-18T00:00:00Z'), - ]); - - BackfillFindingLifecycleJob::dispatchSync( - tenantId: (int) $tenant->getKey(), - workspaceId: (int) $tenant->workspace_id, - initiatorUserId: (int) $user->getKey(), - ); - - $tenantId = (int) $tenant->getKey(); - $expectedRecurrenceKey = hash( - 'sha256', - sprintf('drift:%d:%s:policy:%s:policy_snapshot:modified', $tenantId, $scopeKey, 'policy-dupe'), - ); - - expect(Finding::query() - ->where('tenant_id', $tenantId) - ->where('finding_type', Finding::FINDING_TYPE_DRIFT) - ->where('recurrence_key', $expectedRecurrenceKey) - ->count())->toBe(1); - - $open->refresh(); - $duplicate->refresh(); - - expect($open->recurrence_key)->toBe($expectedRecurrenceKey) - ->and($open->status)->toBe(Finding::STATUS_NEW); - - expect($duplicate->recurrence_key)->toBeNull() - ->and($duplicate->status)->toBe(Finding::STATUS_CLOSED) - ->and($duplicate->resolved_reason)->toBeNull() - ->and($duplicate->resolved_at)->toBeNull() - ->and($duplicate->closed_reason)->toBe(Finding::CLOSE_REASON_DUPLICATE) - ->and($duplicate->closed_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00'); - - CarbonImmutable::setTestNow(); -}); diff --git a/apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php b/apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php new file mode 100644 index 00000000..e55e51f0 --- /dev/null +++ b/apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php @@ -0,0 +1,61 @@ +create(); + createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); + + $service = app(FindingWorkflowService::class); + $finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW, [ + 'owner_user_id' => null, + 'assignee_user_id' => null, + 'sla_days' => 14, + 'due_at' => now()->addDays(14), + ]); + + $triaged = $service->triage($finding, $tenant, $owner); + $assigned = $service->assign( + finding: $triaged, + tenant: $tenant, + actor: $owner, + assigneeUserId: (int) $assignee->getKey(), + ownerUserId: (int) $owner->getKey(), + ); + $inProgress = $service->startProgress($assigned, $tenant, $owner); + $resolved = $service->resolve($inProgress, $tenant, $owner, Finding::RESOLVE_REASON_REMEDIATED); + $riskAccepted = $service->riskAccept( + $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), + $tenant, + $owner, + Finding::CLOSE_REASON_ACCEPTED_RISK, + ); + + expect($triaged->status)->toBe(Finding::STATUS_TRIAGED) + ->and($triaged->triaged_at)->not->toBeNull() + ->and((int) $assigned->owner_user_id)->toBe((int) $owner->getKey()) + ->and((int) $assigned->assignee_user_id)->toBe((int) $assignee->getKey()) + ->and($assigned->sla_days)->toBe(14) + ->and($assigned->due_at)->not->toBeNull() + ->and($inProgress->status)->toBe(Finding::STATUS_IN_PROGRESS) + ->and($inProgress->in_progress_at)->not->toBeNull() + ->and($resolved->status)->toBe(Finding::STATUS_RESOLVED) + ->and($resolved->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED) + ->and($riskAccepted->status)->toBe(Finding::STATUS_RISK_ACCEPTED) + ->and($riskAccepted->closed_reason)->toBe(Finding::CLOSE_REASON_ACCEPTED_RISK); + + expect($this->latestFindingAudit($triaged, AuditActionId::FindingTriaged))->not->toBeNull() + ->and($this->latestFindingAudit($assigned, AuditActionId::FindingAssigned))->not->toBeNull() + ->and($this->latestFindingAudit($inProgress, AuditActionId::FindingInProgress))->not->toBeNull() + ->and($this->latestFindingAudit($resolved, AuditActionId::FindingResolved))->not->toBeNull() + ->and($this->latestFindingAudit($riskAccepted, AuditActionId::FindingRiskAccepted))->not->toBeNull(); +}); diff --git a/apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php b/apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php deleted file mode 100644 index 89545345..00000000 --- a/apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php +++ /dev/null @@ -1,101 +0,0 @@ -create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - OperationalControlActivation::factory()->workspaceScoped()->create([ - 'control_key' => 'findings.lifecycle.backfill', - 'workspace_id' => (int) $tenant->workspace_id, - 'reason_text' => 'Workspace-specific pause.', - ]); - - $this->actingAs($user); - Filament::setTenant($tenant, true); - - Livewire::test(ListFindings::class) - ->assertActionExists('backfill_lifecycle') - ->assertActionEnabled('backfill_lifecycle') - ->callAction('backfill_lifecycle') - ->assertNotified('Findings lifecycle backfill paused'); - - expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0); - - $audit = AuditLog::query() - ->where('action', AuditActionId::OperationalControlExecutionBlocked->value) - ->latest('id') - ->first(); - - expect($audit)->not->toBeNull() - ->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id) - ->and($audit?->tenant_id)->toBe((int) $tenant->getKey()) - ->and($audit?->status)->toBe('blocked') - ->and($audit?->metadata['control_key'] ?? null)->toBe('findings.lifecycle.backfill') - ->and($audit?->metadata['workspace_id'] ?? null)->toBe((int) $tenant->workspace_id); -}); - -it('does not block findings backfill for a different workspace when the pause is workspace-scoped', function (): void { - Queue::fake(); - - [$blockedUser, $blockedTenant] = createUserWithTenant(role: 'owner'); - [$allowedUser, $allowedTenant] = createUserWithTenant(role: 'owner'); - - Finding::factory()->create([ - 'tenant_id' => (int) $allowedTenant->getKey(), - 'due_at' => null, - ]); - - OperationalControlActivation::factory()->workspaceScoped()->create([ - 'control_key' => 'findings.lifecycle.backfill', - 'workspace_id' => (int) $blockedTenant->workspace_id, - 'reason_text' => 'Paused only for the blocked workspace.', - ]); - - $this->actingAs($allowedUser); - Filament::setTenant($allowedTenant, true); - - Livewire::test(ListFindings::class) - ->assertActionExists('backfill_lifecycle') - ->assertActionEnabled('backfill_lifecycle') - ->callAction('backfill_lifecycle'); - - $run = OperationRun::query() - ->where('type', 'findings.lifecycle.backfill') - ->where('tenant_id', (int) $allowedTenant->getKey()) - ->latest('id') - ->first(); - - expect($run)->not->toBeNull(); - - Queue::assertPushed(BackfillFindingLifecycleJob::class, function (BackfillFindingLifecycleJob $job) use ($allowedTenant): bool { - return $job->tenantId === (int) $allowedTenant->getKey() - && $job->workspaceId === (int) $allowedTenant->workspace_id; - }); - - expect(AuditLog::query() - ->where('action', AuditActionId::OperationalControlExecutionBlocked->value) - ->where('tenant_id', (int) $allowedTenant->getKey()) - ->exists())->toBeFalse(); -}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php b/apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php new file mode 100644 index 00000000..72e3a713 --- /dev/null +++ b/apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php @@ -0,0 +1,33 @@ +for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListFindings::class) + ->assertActionDoesNotExist('backfill_lifecycle') + ->assertActionExists('triage_all_matching') + ->assertTableActionVisible('triage', $finding) + ->assertTableActionVisible('assign', $finding) + ->assertTableActionVisible('resolve', $finding) + ->assertTableActionVisible('request_exception', $finding); + + expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->exists())->toBeFalse(); +}); diff --git a/apps/platform/tests/Feature/Guards/LivewireTrustedStateGuardTest.php b/apps/platform/tests/Feature/Guards/LivewireTrustedStateGuardTest.php index 4e4fb5e9..17669df0 100644 --- a/apps/platform/tests/Feature/Guards/LivewireTrustedStateGuardTest.php +++ b/apps/platform/tests/Feature/Guards/LivewireTrustedStateGuardTest.php @@ -12,7 +12,6 @@ function livewireTrustedStateFirstSliceFixtures(): array return [ TrustedStatePolicy::MANAGED_TENANT_ONBOARDING_WIZARD => 'app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php', TrustedStatePolicy::TENANT_REQUIRED_PERMISSIONS => 'app/Filament/Pages/TenantRequiredPermissions.php', - TrustedStatePolicy::SYSTEM_RUNBOOKS => 'app/Filament/System/Pages/Ops/Runbooks.php', ]; } diff --git a/apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php b/apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php index ee22abed..587063a2 100644 --- a/apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php +++ b/apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php @@ -10,12 +10,12 @@ $checks = [ [ 'file' => $root.'/app/Filament/Resources/FindingResource/Pages/ListFindings.php', - 'required' => [ - 'FindingsLifecycleBackfillRunbookService', - 'OperationalControlBlockedException', - 'FindingsLifecycleBackfillScope::singleTenant(', - ], + 'required' => [], 'forbidden' => [ + 'FindingsLifecycleBackfillRunbookService', + 'FindingsLifecycleBackfillScope', + 'Backfill findings lifecycle', + 'backfill_lifecycle', "config('tenantpilot.allow_admin_maintenance_actions'", 'allow_admin_maintenance_actions', 'OperationalControlActivation::', @@ -23,12 +23,12 @@ ], [ 'file' => $root.'/app/Filament/System/Pages/Ops/Runbooks.php', - 'required' => [ - 'FindingsLifecycleBackfillRunbookService', - 'OperationalControlBlockedException', - '$runbookService->start(', - ], + 'required' => [], 'forbidden' => [ + 'FindingsLifecycleBackfillRunbookService', + 'FindingsLifecycleBackfillScope', + 'findings.lifecycle.backfill', + 'Rebuild Findings Lifecycle', 'OperationalControlActivation::', "config('tenantpilot.allow_admin_maintenance_actions'", ], @@ -66,4 +66,16 @@ expect($source)->not->toContain($needle); } } -})->group('surface-guard'); \ No newline at end of file + + foreach ([ + $root.'/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php', + $root.'/app/Console/Commands/TenantpilotRunDeployRunbooks.php', + $root.'/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php', + $root.'/app/Services/Runbooks/FindingsLifecycleBackfillScope.php', + $root.'/app/Jobs/BackfillFindingLifecycleJob.php', + $root.'/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php', + $root.'/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php', + ] as $removedPath) { + expect(file_exists($removedPath))->toBeFalse("Removed findings lifecycle backfill artifact still exists: {$removedPath}"); + } +})->group('surface-guard'); diff --git a/apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php b/apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php new file mode 100644 index 00000000..74c62861 --- /dev/null +++ b/apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php @@ -0,0 +1,42 @@ +create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPS_CONTROLS_MANAGE, + ], + 'is_active' => true, + ]); + + $this->actingAs($user, 'platform'); + + $this->get(Controls::getUrl(panel: 'system')) + ->assertSuccessful() + ->assertDontSee('Findings lifecycle backfill') + ->assertDontSee("mountAction('pause_findings_lifecycle_backfill')", escape: false) + ->assertDontSee("mountAction('resume_findings_lifecycle_backfill')", escape: false) + ->assertDontSee("mountAction('view_history_findings_lifecycle_backfill')", escape: false); + + $catalog = app(OperationalControlCatalog::class); + + expect($catalog->keys())->not->toContain('findings.lifecycle.backfill') + ->and(fn (): array => $catalog->definition('findings.lifecycle.backfill')) + ->toThrow(InvalidArgumentException::class); +}); diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php deleted file mode 100644 index 7b5f87e9..00000000 --- a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php +++ /dev/null @@ -1,87 +0,0 @@ -create([ - 'tenant_id' => null, - 'external_id' => 'platform', - 'name' => 'Platform', - ]); -}); - -it('does not crash when audit logging fails and still finalizes a failed run', function () { - $this->mock(AuditLogger::class, function ($mock): void { - $mock->shouldReceive('log')->andThrow(new RuntimeException('audit unavailable')); - }); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - $runbook = app(FindingsLifecycleBackfillRunbookService::class); - - $run = $runbook->start( - scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), - initiator: $user, - reason: null, - source: 'system_ui', - ); - - $runs = app(OperationRunService::class); - - $runs->updateRun( - $run, - status: 'completed', - outcome: 'failed', - failures: [ - [ - 'code' => 'test.failed', - 'message' => 'Forced failure for audit fail-safe test.', - ], - ], - ); - - $runbook->maybeFinalize($run); - - $run->refresh(); - - expect($run->status)->toBe('completed'); - expect($run->outcome)->toBe('failed'); -}); diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php deleted file mode 100644 index 8d2dc271..00000000 --- a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php +++ /dev/null @@ -1,111 +0,0 @@ -create([ - 'tenant_id' => null, - 'external_id' => 'platform', - 'name' => 'Platform', - ]); - - config()->set('tenantpilot.break_glass.enabled', true); - config()->set('tenantpilot.break_glass.ttl_minutes', 15); -}); - -it('requires a reason when break-glass is active and records break-glass on the run + audit', function () { - Queue::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $customerTenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $customerTenant->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - PlatformCapabilities::USE_BREAK_GLASS, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(Dashboard::class) - ->callAction('enter_break_glass', data: [ - 'reason' => 'Recovery test', - ]) - ->assertHasNoActionErrors(); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT, - 'tenant_id' => (int) $customerTenant->getKey(), - ]) - ->assertSet('preflight.affected_count', 1) - ->callAction('run', data: []) - ->assertHasActionErrors(['reason_code', 'reason_text']); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT, - 'tenant_id' => (int) $customerTenant->getKey(), - ]) - ->assertSet('preflight.affected_count', 1) - ->callAction('run', data: [ - 'reason_code' => 'INCIDENT', - 'reason_text' => 'Break-glass backfill required', - ]) - ->assertHasNoActionErrors() - ->assertNotified(); - - $run = OperationRun::query() - ->where('type', 'findings.lifecycle.backfill') - ->latest('id') - ->first(); - - expect($run)->not->toBeNull(); - expect((int) $run?->tenant_id)->toBe((int) $customerTenant->getKey()); - expect(data_get($run?->context, 'platform_initiator.is_break_glass'))->toBeTrue(); - expect(data_get($run?->context, 'reason.reason_code'))->toBe('INCIDENT'); - expect(data_get($run?->context, 'reason.reason_text'))->toBe('Break-glass backfill required'); - - $audit = AuditLog::query() - ->where('action', 'platform.ops.runbooks.start') - ->latest('id') - ->first(); - - expect($audit)->not->toBeNull(); - expect($audit?->metadata['is_break_glass'] ?? null)->toBe(true); - expect($audit?->metadata['reason_code'] ?? null)->toBe('INCIDENT'); - expect($audit?->metadata['reason_text'] ?? null)->toBe('Break-glass backfill required'); -}); diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php deleted file mode 100644 index 645a695d..00000000 --- a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php +++ /dev/null @@ -1,78 +0,0 @@ -create([ - 'tenant_id' => null, - 'external_id' => 'platform', - 'name' => 'Platform', - ]); -}); - -it('is idempotent: after a successful run, preflight reports nothing to do', function () { - Queue::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - $runbook = app(FindingsLifecycleBackfillRunbookService::class); - - $initial = $runbook->preflight(FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey())); - - expect($initial['affected_count'])->toBe(1); - - $runbook->start( - scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), - initiator: null, - reason: null, - source: 'system_ui', - ); - - $job = new BackfillFindingLifecycleJob( - tenantId: (int) $tenant->getKey(), - workspaceId: (int) $tenant->workspace_id, - initiatorUserId: null, - ); - - $job->handle( - app(OperationRunService::class), - app(\App\Services\Findings\FindingSlaPolicy::class), - $runbook, - ); - - $after = $runbook->preflight(FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey())); - - expect($after['affected_count'])->toBe(0); - - expect(fn () => $runbook->start( - scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), - initiator: null, - reason: null, - source: 'system_ui', - ))->toThrow(ValidationException::class); -}); diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php deleted file mode 100644 index 131b8d49..00000000 --- a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php +++ /dev/null @@ -1,202 +0,0 @@ -create([ - 'tenant_id' => null, - 'external_id' => 'platform', - 'name' => 'Platform', - ]); -}); - -it('computes single-tenant preflight counts', function () { - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - ]); - - $service = app(FindingsLifecycleBackfillRunbookService::class); - - $result = $service->preflight(FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey())); - - expect($result['total_count'])->toBe(2); - expect($result['affected_count'])->toBe(1); -}); - -it('computes all-tenants preflight counts scoped to the platform workspace', function () { - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenantA = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - $tenantB = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - $otherTenant = Tenant::factory()->create(); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenantA->getKey(), - 'due_at' => null, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenantB->getKey(), - 'sla_days' => null, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $otherTenant->getKey(), - 'due_at' => null, - ]); - - $service = app(FindingsLifecycleBackfillRunbookService::class); - - $result = $service->preflight(FindingsLifecycleBackfillScope::allTenants()); - - expect($result['estimated_tenants'])->toBe(2); - expect($result['total_count'])->toBe(2); - expect($result['affected_count'])->toBe(2); -}); - -it('accepts an allowed single-tenant selection during preflight', function () { - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(\App\Filament\System\Pages\Ops\Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT, - 'tenant_id' => (int) $tenant->getKey(), - ]) - ->assertHasNoActionErrors() - ->assertSet('findingsTenantId', (int) $tenant->getKey()) - ->assertSet('preflight.affected_count', 1); -}); - -it('rejects platform tenant selection during preflight', function () { - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - assertScopedSelectorRejected( - Livewire::test(\App\Filament\System\Pages\Ops\Runbooks::class), - 'preflight', - [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT, - 'tenant_id' => (int) $platformTenant->getKey(), - ], - ); -}); - -it('resets to an all-tenant trusted scope even when stale single-tenant selector state remains on the page', function () { - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenantA = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - 'name' => 'Scope Tenant A', - ]); - - $tenantB = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - 'name' => 'Scope Tenant B', - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenantA->getKey(), - 'due_at' => null, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenantB->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(\App\Filament\System\Pages\Ops\Runbooks::class) - ->set('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) - ->set('findingsTenantId', (int) $tenantA->getKey()) - ->set('scopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) - ->set('tenantId', (int) $tenantA->getKey()) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, - 'tenant_id' => (int) $tenantA->getKey(), - ]) - ->assertHasNoActionErrors() - ->assertSet('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) - ->assertSet('findingsTenantId', null) - ->assertSet('scopeMode', FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) - ->assertSet('tenantId', null) - ->assertSet('preflight.estimated_tenants', 2) - ->assertSet('preflight.affected_count', 2); -}); diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php deleted file mode 100644 index 0d472d75..00000000 --- a/apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php +++ /dev/null @@ -1,253 +0,0 @@ -create([ - 'tenant_id' => null, - 'external_id' => 'platform', - 'name' => 'Platform', - ]); -}); - -it('disables running when preflight indicates nothing to do', function () { - Queue::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, - ]) - ->assertSet('preflight.affected_count', 0) - ->assertActionDisabled('run'); -}); - -it('requires typed confirmation and a reason for all-tenants runs', function () { - Queue::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, - ]) - ->assertSet('preflight.affected_count', 1) - ->callAction('run', data: []) - ->assertHasActionErrors([ - 'typed_confirmation', - 'reason_code', - 'reason_text', - ]); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, - ]) - ->assertSet('preflight.affected_count', 1) - ->callAction('run', data: [ - 'typed_confirmation' => 'backfill', - 'reason_code' => 'DATA_REPAIR', - 'reason_text' => 'Test run', - ]) - ->assertHasActionErrors(['typed_confirmation']); -}); - -it('rejects forged single-tenant selector state on run and records no run or start audit', function () { - Queue::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $allowedTenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $allowedTenant->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT, - 'tenant_id' => (int) $allowedTenant->getKey(), - ]) - ->assertSet('preflight.affected_count', 1) - ->set('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) - ->set('findingsTenantId', (int) $platformTenant->getKey()) - ->set('scopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) - ->set('tenantId', (int) $platformTenant->getKey()) - ->callAction('run', data: []) - ->assertHasActionErrors(); - - expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0) - ->and(AuditLog::query()->where('action', 'platform.ops.runbooks.start')->count())->toBe(0); -}); - -it('records a start audit with the canonical single-tenant scope when an allowed run is queued', function () { - Queue::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT, - 'tenant_id' => (int) $tenant->getKey(), - ]) - ->assertSet('preflight.affected_count', 1) - ->callAction('run', data: []) - ->assertHasNoActionErrors() - ->assertNotified('Findings lifecycle backfill queued'); - - $run = OperationRun::query() - ->where('type', 'findings.lifecycle.backfill') - ->latest('id') - ->first(); - - expect($run)->not->toBeNull(); - - $audit = AuditLog::query() - ->where('action', 'platform.ops.runbooks.start') - ->latest('id') - ->first(); - - expect($audit)->not->toBeNull() - ->and($audit?->resource_id)->toBe((string) $run?->getKey()) - ->and($audit?->metadata['scope'] ?? null)->toBe(FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) - ->and($audit?->metadata['target_tenant_id'] ?? null)->toBe((int) $tenant->getKey()) - ->and($audit?->metadata['operation_run_id'] ?? null)->toBe((int) $run?->getKey()); -}); - -it('returns 403 for runbook execution when the platform user is in scope but lacks run capability', function () { - Queue::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT, - 'tenant_id' => (int) $tenant->getKey(), - ]) - ->assertSet('preflight.affected_count', 1) - ->callAction('run', data: []) - ->assertForbidden(); - - expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0) - ->and(AuditLog::query()->where('action', 'platform.ops.runbooks.start')->count())->toBe(0); -}); diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php deleted file mode 100644 index 3c61ef99..00000000 --- a/apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php +++ /dev/null @@ -1,89 +0,0 @@ -create([ - 'tenant_id' => null, - 'external_id' => 'platform', - 'name' => 'Platform', - ]); -}); - -it('blocks all-tenant findings lifecycle runbooks when the control is globally paused', function (): void { - Queue::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - OperationalControlActivation::factory()->forGlobalScope()->create([ - 'control_key' => 'findings.lifecycle.backfill', - 'reason_text' => 'Paused during incident response.', - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => 'all_tenants', - ]) - ->assertSet('preflight.affected_count', 1) - ->callAction('run', data: [ - 'typed_confirmation' => 'BACKFILL', - 'reason_code' => 'DATA_REPAIR', - 'reason_text' => 'Attempt blocked by control', - ]) - ->assertNotified('Findings lifecycle backfill paused'); - - expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0); - - $audit = AuditLog::query() - ->where('action', AuditActionId::OperationalControlExecutionBlocked->value) - ->latest('id') - ->first(); - - expect($audit)->not->toBeNull() - ->and($audit?->workspace_id)->toBeNull() - ->and($audit?->tenant_id)->toBeNull() - ->and($audit?->status)->toBe('blocked') - ->and($audit?->metadata['control_key'] ?? null)->toBe('findings.lifecycle.backfill') - ->and($audit?->metadata['requested_scope'] ?? null)->toBe('all_tenants'); -}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php deleted file mode 100644 index a2d95695..00000000 --- a/apps/platform/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php +++ /dev/null @@ -1,89 +0,0 @@ -create([ - 'tenant_id' => null, - 'external_id' => 'platform', - 'name' => 'Platform', - ]); -}); - -it('uses an intent-only toast with a working view-run link and does not emit queued database notifications', function () { - Queue::fake(); - NotificationFacade::fake(); - - $platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail(); - - $tenant = Tenant::factory()->create([ - 'workspace_id' => (int) $platformTenant->workspace_id, - ]); - - Finding::factory()->create([ - 'tenant_id' => (int) $tenant->getKey(), - 'due_at' => null, - ]); - - $user = PlatformUser::factory()->create([ - 'capabilities' => [ - PlatformCapabilities::ACCESS_SYSTEM_PANEL, - PlatformCapabilities::OPS_VIEW, - PlatformCapabilities::RUNBOOKS_VIEW, - PlatformCapabilities::RUNBOOKS_RUN, - PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL, - ], - 'is_active' => true, - ]); - - $this->actingAs($user, 'platform'); - - Livewire::test(Runbooks::class) - ->callAction('preflight', data: [ - 'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS, - ]) - ->assertSet('preflight.affected_count', 1) - ->callAction('run', data: [ - 'typed_confirmation' => 'BACKFILL', - 'reason_code' => 'DATA_REPAIR', - 'reason_text' => 'Operator test', - ]) - ->assertHasNoActionErrors() - ->assertNotified('Findings lifecycle backfill queued'); - - NotificationFacade::assertNothingSent(); - expect(DatabaseNotification::query()->count())->toBe(0); - - $run = OperationRun::query() - ->where('type', 'findings.lifecycle.backfill') - ->latest('id') - ->first(); - - expect($run)->not->toBeNull(); - - $viewUrl = SystemOperationRunLinks::view($run); - - $this->get($viewUrl) - ->assertSuccessful() - ->assertSee('Operation #'.(int) $run?->getKey()); -}); diff --git a/apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php b/apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php new file mode 100644 index 00000000..7caeced1 --- /dev/null +++ b/apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php @@ -0,0 +1,59 @@ +create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPS_VIEW, + PlatformCapabilities::RUNBOOKS_VIEW, + PlatformCapabilities::RUNBOOKS_RUN, + ], + 'is_active' => true, + ]); + + $this->actingAs($user, 'platform'); + + $this->get(Runbooks::getUrl(panel: 'system')) + ->assertSuccessful() + ->assertSee('No supported runbooks') + ->assertDontSee('Rebuild Findings Lifecycle') + ->assertDontSee('Backfills legacy findings lifecycle fields') + ->assertDontSee('Preflight') + ->assertDontSee('preflight') + ->assertDontSee('Run: Rebuild Findings Lifecycle') + ->assertDontSee('BACKFILL'); + + Livewire::test(Runbooks::class) + ->assertActionDoesNotExist('preflight') + ->assertActionDoesNotExist('run'); +}); + +it('preserves runbooks view authorization semantics after removing the backfill runbook', function (): void { + $user = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::OPS_VIEW, + ], + 'is_active' => true, + ]); + + $this->actingAs($user, 'platform') + ->get(Runbooks::getUrl(panel: 'system')) + ->assertForbidden(); +}); diff --git a/apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php b/apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php new file mode 100644 index 00000000..af2f156e --- /dev/null +++ b/apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php @@ -0,0 +1,14 @@ +toBeFalse() + ->and(PlatformCapabilities::all())->not->toContain('platform.runbooks.findings.lifecycle_backfill'); + + expect((string) file_get_contents(database_path('seeders/PlatformUserSeeder.php'))) + ->not->toContain('RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL') + ->not->toContain('platform.runbooks.findings.lifecycle_backfill'); +}); diff --git a/apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php b/apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php new file mode 100644 index 00000000..0e7adf1f --- /dev/null +++ b/apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php @@ -0,0 +1,12 @@ +not->toHaveKey('findings.lifecycle.backfill') + ->and(OperationCatalog::aliasInventory())->not->toHaveKey('findings.lifecycle.backfill') + ->and(OperationCatalog::rawValuesForCanonical('findings.lifecycle.backfill'))->toBe([]) + ->and(OperationCatalog::label('findings.lifecycle.backfill'))->toBe('Unknown operation'); +}); diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 071d1139..06362df3 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -473,7 +473,6 @@ ### Deployment: Dokploy (staging → production) ### Platform runbooks -- `FindingsLifecycleBackfillRunbookService` ([app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php](app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php)) — safe backfill of findings lifecycle fields - Accessible at `/system/ops/runbooks` with platform capabilities --- diff --git a/specs/253-remove-findings-backfill-runtime-surfaces/checklists/requirements.md b/specs/253-remove-findings-backfill-runtime-surfaces/checklists/requirements.md new file mode 100644 index 00000000..ad337cdd --- /dev/null +++ b/specs/253-remove-findings-backfill-runtime-surfaces/checklists/requirements.md @@ -0,0 +1,48 @@ +# Specification Quality Checklist: Remove Findings Lifecycle Backfill Runtime Surfaces + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-28 +**Feature**: specs/253-remove-findings-backfill-runtime-surfaces/spec.md + +## Content Quality + +- [x] No language/framework/API design leakage; concrete repo surfaces, commands, and labels are named only because this cleanup deletes those exact shipped traces. +- [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 unintended implementation design leakage remains beyond the explicit cleanup special-case for named repo-visible traces + +## Test Governance Review + +- [x] Lane fit is explicit: the package uses `fast-feedback` and `confidence`, plus one retained `heavy-governance` guard in `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` so operational-control bypass residue cannot survive the cleanup silently. +- [x] No new browser or heavy-governance family is introduced; the retained guard stays explicit, bounded, and tied to operational-control source-trace removal only. +- [x] Suite-cost outcome is net-negative: backfill-only tests, lane traces, and helper residue are removed in the same slice instead of widening shared defaults. + +## Review Outcome + +- [x] Review outcome class: `acceptable-special-case` +- [x] Workflow outcome: `keep` +- [x] Review-note location is explicit: the heavy-governance retention note lives in `spec.md`, `plan.md`, `tasks.md`, and the final preparation report. + +## Notes + +- The spec intentionally names concrete routes, commands, labels, and catalog keys because the product value of this slice is the removal of those specific repo-visible runtime surfaces. +- The slice stays small by deleting visible repair tooling only; acknowledged-status cleanup and creation-time invariant hardening remain explicit follow-up candidates. +- Validation pass complete: no clarification markers remain, LEAN-001 cleanup posture is explicit, and tenant-owned findings continue to treat `workspace_id` plus `tenant_id` as required anchors. \ No newline at end of file diff --git a/specs/253-remove-findings-backfill-runtime-surfaces/contracts/findings-backfill-runtime-surface-removal.contract.yaml b/specs/253-remove-findings-backfill-runtime-surfaces/contracts/findings-backfill-runtime-surface-removal.contract.yaml new file mode 100644 index 00000000..cc644b2c --- /dev/null +++ b/specs/253-remove-findings-backfill-runtime-surfaces/contracts/findings-backfill-runtime-surface-removal.contract.yaml @@ -0,0 +1,108 @@ +version: 1 +kind: findings-backfill-runtime-surface-removal + +scope: + goal: remove findings lifecycle backfill runtime and product surfaces only + non_goals: + - acknowledged-status semantics cleanup + - creation-time finding invariant hardening + - replacement repair surface + - historical data migration + - compatibility alias or no-op command preservation + +removed_entry_points: + system_runbooks: + route: /system/ops/runbooks + removed_labels: + - Rebuild Findings Lifecycle + - Preflight + - Run… + owner_files: + - apps/platform/app/Filament/System/Pages/Ops/Runbooks.php + - apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php + tenant_findings: + route: /admin/t/{tenant}/findings + removed_labels: + - Backfill findings lifecycle + - Open operation + owner_files: + - apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php + - apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php + console: + removed_commands: + - tenantpilot:findings:backfill-lifecycle + - tenantpilot:run-deploy-runbooks + owner_files: + - apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php + - apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php + deploy_runtime: + requirement: + - no deploy hook, runtime hook, schedule, or bootstrap path may queue or start findings lifecycle backfill after cleanup + +removed_runtime_cluster: + service: + - apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php + jobs: + - apps/platform/app/Jobs/BackfillFindingLifecycleJob.php + - apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php + - apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php + operation_type: + - findings.lifecycle.backfill + +removed_registry_traces: + capabilities: + - platform.runbooks.findings.lifecycle_backfill + seeders: + - apps/platform/database/seeders/PlatformUserSeeder.php + operation_catalog: + canonical_types: + - findings.lifecycle.backfill + aliases: + - findings.lifecycle.backfill + system_console_triage: + retryable_types: + - findings.lifecycle.backfill + cancelable_types: + - findings.lifecycle.backfill + operational_controls: + notes: + - the active operational-control catalog already rejects findings.lifecycle.backfill + - remaining blocked-start branches and tests for that key must be removed rather than normalized + +retained_behavior: + findings_workflow_actions: + - triage + - start_progress + - assign + - resolve + - close + - request_exception + - reopen + guarantees: + - findings workflow status, ownership, SLA, due-date, and reviewable behavior remain unchanged + - no replacement repair or maintenance surface is introduced + +legacy_data_posture: + operation_runs: + historical rows may remain stored without new canonical alias, retry, cancel, or operator-UX guarantee + audit_logs: + historical start, blocked, completed, or failed rows may remain stored without migration or compatibility layer + +validation_expectations: + no_new_side_effects: + - no supported surface may create a new OperationRun of type findings.lifecycle.backfill + - no supported surface may dispatch the deleted backfill jobs + absence_proof: + - system runbooks page exposes no findings lifecycle backfill card or action + - tenant findings page exposes no findings lifecycle backfill header action + - supported command catalog exposes no findings lifecycle backfill command entry + - operational-control and operation-label surfaces expose no live findings lifecycle backfill trace + regression_proof: + - representative canonical findings workflow actions still succeed unchanged + lane_classification: + required: + - fast-feedback + - confidence + - heavy-governance + excluded: + - browser \ No newline at end of file diff --git a/specs/253-remove-findings-backfill-runtime-surfaces/data-model.md b/specs/253-remove-findings-backfill-runtime-surfaces/data-model.md new file mode 100644 index 00000000..772792bc --- /dev/null +++ b/specs/253-remove-findings-backfill-runtime-surfaces/data-model.md @@ -0,0 +1,121 @@ +# Data Model — Remove Findings Lifecycle Backfill Runtime Surfaces + +**Spec**: [spec.md](spec.md) + +This feature is subtractive. It introduces no new persisted truth and no migration. The data-model impact is the removal of one obsolete runtime family and the reaffirmation of the canonical findings workflow as the only supported path. + +## Existing Canonical Entities Reused + +### Finding (`findings`) + +**Purpose**: Tenant-owned findings workflow truth. + +**Key fields (existing)**: +- `id` +- `workspace_id` +- `tenant_id` +- `status` +- `triaged_at` +- `first_seen_at` +- `last_seen_at` +- `times_seen` +- `sla_days` +- `due_at` + +**Feature use**: +- Remains the canonical workflow truth for triage, assignment, progress, resolve, risk acceptance, ownership, SLA, due-date, and reviewable behavior. +- Continues to require both `workspace_id` and `tenant_id` as non-null ownership anchors. +- Is in scope only for regression protection, not for lifecycle redesign. + +### OperationRun (`operation_runs`) + +**Purpose**: Existing canonical execution truth for supported long-running operations. + +**Key fields (existing)**: +- `id` +- `workspace_id` +- `tenant_id` +- `type` +- `status` +- `outcome` +- `context` + +**Feature use**: +- After cleanup, no supported system, tenant, CLI, or deploy/runtime path may create a new `OperationRun` with `type = findings.lifecycle.backfill`. +- Historical rows may remain stored as legacy data, but the feature does not preserve special retry, cancel, label, or alias handling for them. + +### AuditLog (`audit_logs`) + +**Purpose**: Existing audit truth for prior lifecycle-backfill starts, blocked starts, and completions. + +**Feature use**: +- No new audit action family is introduced. +- Historical rows may remain stored without new cleanup migration or compatibility layer. +- Canonical findings workflow audit behavior remains unchanged and is protected through regression testing. + +### OperationalControlActivation (`operational_control_activations`) + +**Purpose**: Existing runtime-safety truth for live operational controls. + +**Feature use**: +- The cleanup should not add or preserve a `findings.lifecycle.backfill` control key. +- Existing backfill-specific blocked-start branches and tests should be removed because the active control catalog already rejects the key. + +## Removed Runtime Families + +### FindingsLifecycleBackfillSurface (derived, non-persisted) + +**Purpose**: Describes each currently productized entry point that must disappear in the cleanup. + +**Runtime fields**: +- `surface_id` — unique identifier such as `system.ops.runbooks`, `tenant.findings.list`, `console.tenantpilot.findings.backfill-lifecycle`, or `console.tenantpilot.run-deploy-runbooks` +- `entry_type` — `runbook`, `header_action`, `command`, `deploy_hook`, `operation_label`, `capability_trace`, or `test_trace` +- `operator_label` — current visible product label such as `Rebuild Findings Lifecycle` or `Backfill findings lifecycle` +- `owner_path` — current source file that makes the surface real +- `start_seam` — shared service or registry seam that currently powers the entry point + +**Feature use**: +- Drives removal planning so the cleanup deletes the source of truth for each surface instead of only hiding one page affordance. + +### FindingsLifecycleBackfillExecutionCluster (derived, non-persisted) + +**Purpose**: The dedicated runtime chain that currently starts, queues, and finalizes lifecycle backfill. + +**Current members**: +- `FindingsLifecycleBackfillRunbookService` +- `TenantpilotBackfillFindingLifecycle` +- `TenantpilotRunDeployRunbooks` +- `BackfillFindingLifecycleJob` +- `BackfillFindingLifecycleWorkspaceJob` +- `BackfillFindingLifecycleTenantIntoWorkspaceRunJob` + +**Lifecycle rule**: +- The cluster is deleted in the same slice. No dormant flag, replacement command, or service shim is retained. + +### FindingsLifecycleBackfillTrace (derived, non-persisted) + +**Purpose**: Registry, catalog, seed, test, and doc references that still advertise lifecycle backfill as supported behavior. + +**Trace fields**: +- `trace_type` — `capability`, `seeder`, `operation_type`, `operation_alias`, `triage_support`, `control_branch`, `test`, `guard`, or `doc` +- `identifier` — exact key such as `platform.runbooks.findings.lifecycle_backfill` or `findings.lifecycle.backfill` +- `owner_path` — file that currently carries the trace +- `removal_reason` — why the trace must disappear with the runtime surface + +**Feature use**: +- Ensures cleanup removes registry and test ballast in the same slice instead of leaving the repo to advertise deleted behavior indirectly. + +## Data Ownership Notes + +- No new tables, settings, or persisted aliases are introduced. +- No migration, historical data rewrite, or archival compatibility layer is planned. +- Historical `OperationRun` and `AuditLog` rows are tolerated legacy data and do not justify preserving the removed runtime path. +- Findings remain tenant-owned and continue to require both `workspace_id` and `tenant_id` as canonical ownership anchors. +- Operational-control truth remains bounded to currently supported controls only; this slice should not keep a removed backfill control key alive through hidden test fixtures or service branches. + +## Removal Invariants + +- No supported path may create a new `OperationRun` with `type = findings.lifecycle.backfill`. +- No supported page, command catalog, or deploy/runtime hook may advertise lifecycle backfill as an available operator action. +- No compatibility shim, no-op command shell, or fallback alias may remain for the removed path. +- Canonical findings workflow behavior remains unchanged and continues to operate on the existing `Finding` truth. \ No newline at end of file diff --git a/specs/253-remove-findings-backfill-runtime-surfaces/plan.md b/specs/253-remove-findings-backfill-runtime-surfaces/plan.md new file mode 100644 index 00000000..97f76591 --- /dev/null +++ b/specs/253-remove-findings-backfill-runtime-surfaces/plan.md @@ -0,0 +1,237 @@ +# Implementation Plan: Remove Findings Lifecycle Backfill Runtime Surfaces + +**Branch**: `253-remove-findings-backfill-runtime-surfaces` | **Date**: 2026-04-28 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +- Remove all shipped findings lifecycle backfill runtime and product surfaces by deleting the owning backfill service, jobs, commands, and registry traces instead of hiding labels locally. +- Preserve the normal findings workflow exactly as-is for triage, assignment, progress, resolve, risk acceptance, ownership, SLA, due-date, and reviewable finding behavior, with regression proof kept explicit. +- Keep the slice narrow: acknowledged-status cleanup and creation-time lifecycle invariant hardening remain separate follow-up specs, and OperationRun semantics are touched only by removing one obsolete runbook path rather than inventing a new run UX abstraction. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities` +**Storage**: PostgreSQL existing `findings`, `operation_runs`, `audit_logs`, and related runtime tables only; no new persistence, migration, or data backfill is planned +**Testing**: Pest feature tests plus narrow unit and guard coverage +**Validation Lanes**: fast-feedback, confidence, heavy-governance +**Target Platform**: Sail-backed Laravel web application with tenant `/admin/t/{tenant}` surfaces, platform `/system` surfaces, and Artisan command/runtime entry points +**Project Type**: web +**Performance Goals**: after cleanup, no supported surface may enqueue or start `findings.lifecycle.backfill`; surviving findings workflows retain their current performance profile; the slice should reduce runtime and test surface rather than add new overhead +**Constraints**: LEAN-001 replacement over compatibility shims; no replacement repair surface; no findings semantics redesign; no data migration; preserve current `404` vs `403` isolation semantics; no new Filament panel/provider work; no new assets or `filament:assets` deploy changes +**Scale/Scope**: 1 cleanup slice touching 3 operator-facing surfaces, 2 console entry points, 1 shared runbook service cluster, 3 dedicated jobs, capability/operation/triage traces, and the backfill-specific test and docs footprint + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament plus existing shared action and run UX primitives +- **Shared-family relevance**: header actions, runbook launch cards, operation labeling, operational-control and paused-state messaging, command/runtime entry points +- **State layers in scope**: page, action/modal, detail/read-model +- **Audience modes in scope**: operator-MSP, support-platform +- **Decision/diagnostic/raw hierarchy plan**: decision-first / diagnostics-second / support-raw-third +- **Raw/support gating plan**: existing capability-gated diagnostics only; no new raw or support surface is introduced +- **One-primary-action / duplicate-truth control**: remove the maintenance CTA so tenant findings pages keep only canonical findings workflow actions and system pages keep only supported runbooks and supported controls +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: standard-native-filament, monitoring-state-page +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none; source-trace removal is mandatory wherever shared registries or helper paths still emit findings backfill semantics +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `App\Filament\System\Pages\Ops\Runbooks`, `App\Filament\Resources\FindingResource\Pages\ListFindings`, `App\Console\Commands\TenantpilotBackfillFindingLifecycle`, `App\Console\Commands\TenantpilotRunDeployRunbooks`, `App\Services\Runbooks\FindingsLifecycleBackfillRunbookService`, `App\Services\Runbooks\FindingsLifecycleBackfillScope`, `App\Jobs\BackfillFindingLifecycleJob`, `App\Jobs\BackfillFindingLifecycleWorkspaceJob`, `App\Jobs\BackfillFindingLifecycleTenantIntoWorkspaceRunJob`, `App\Support\OperationCatalog`, `App\Support\Auth\PlatformCapabilities`, `App\Services\SystemConsole\OperationRunTriageService`, `App\Support\Livewire\TrustedState\TrustedStatePolicy`, `App\Support\Ui\ActionSurface\ActionSurfaceExemptions`, `Database\Seeders\PlatformUserSeeder`, and the related findings/runbook/console/control tests +- **Shared abstractions reused**: `UiEnforcement`, `OperationUxPresenter`, `OpsUxBrowserEvents`, `OperationRunLinks`, `SystemOperationRunLinks`, `OperationRunService`, `OperationCatalog`, `AuditRecorder`, and `WorkspaceAuditLogger` +- **New abstraction introduced? why?**: none; this is a subtractive cleanup that removes one bounded operation family and its traces +- **Why the existing abstraction was sufficient or insufficient**: the existing shared abstractions are sufficient for surviving findings workflows and surviving operations. The cleanup must converge those shared families back to supported product truth instead of layering a replacement backfill path or compatibility wrapper on top. +- **Bounded deviation / spread control**: none; where a shared catalog, capability registry, triage list, or test helper still names `findings.lifecycle.backfill`, the source trace itself must be removed instead of locally masked + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes +- **Central contract reused**: existing shared OperationRun UX layer for surviving operation types only +- **Delegated UX behaviors**: `N/A` for the removed findings lifecycle backfill path after cleanup; queued toast, `View run` or `Open operation` links, dedupe messaging, browser events, and terminal notifications remain unchanged for every other operation type +- **Surface-owned behavior kept local**: none for the removed path +- **Queued DB-notification policy**: `N/A` for the removed path +- **Terminal notification path**: existing central lifecycle mechanism for surviving operations remains unchanged +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: `N/A` +- **Platform-core seams**: `N/A` +- **Neutral platform terms / contracts preserved**: `N/A` +- **Retained provider-specific semantics and why**: none +- **Bounded extraction or follow-up path**: `N/A` + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Read/write separation: PASS - the slice removes shipped write entry points and introduces no new mutating path +- Inventory-first / snapshots-second: PASS - inventory, backups, and snapshots are untouched +- Graph contract path: PASS - no Microsoft Graph surface changes are involved +- Deterministic capabilities: PASS - one platform capability constant and its seeder grant are removed rather than expanded; no new capability namespace is introduced +- RBAC-UX: PASS - `/system` remains platform-only, `/admin/t/{tenant}` remains tenant-scoped, non-members stay `404`, in-scope users missing capability on surviving actions stay `403`, and cleanup must not widen or hide unrelated authorization semantics +- Workspace isolation / tenant isolation: PASS - findings remain tenant-owned with required `workspace_id` and `tenant_id` anchors; no new cross-tenant surface or leakage path is introduced +- OperationRun observability / Ops-UX: PASS - the feature removes one `OperationRun` start surface only; no new run type, no new feedback surface, and no local start UX dialect are introduced +- OperationRun lifecycle ownership: PASS - no new lifecycle transition path is introduced; surviving operations remain service-owned +- Automation / locking: PASS - queued backfill runtime paths are deleted rather than extended +- Data minimization: PASS - no new persisted data or audit payload family is introduced +- Test governance (`TEST-GOV-001`): PASS - proof remains in narrow feature plus unit lanes, with one retained heavy-governance source-scanning guard kept explicit because operational-control bypass residue must still be blocked after the control key and runbook service are removed +- Proportionality (`PROP-001`) and no premature abstraction (`ABSTR-001`): PASS - the feature removes an obsolete runtime family and adds no new abstraction layer +- Persisted truth (`PERSIST-001`): PASS - no new table, entity, artifact, or alias layer is introduced +- Behavioral state (`STATE-001`): PASS - no new status or reason family is added; acknowledged cleanup remains a separate follow-up +- UI semantics (`UI-SEM-001`): PASS - no new presentation taxonomy is added; labels disappear together with the runtime path +- Shared pattern first (`XCUT-001`): PASS - the cleanup converges shared runbook, action, audit, and operation-label families by deletion instead of creating a parallel exception path +- Provider boundary (`PROV-001`): PASS - no provider/platform seam changes are introduced +- V1 explicitness / few layers (`V1-EXP-001`, `LAYER-001`): PASS - the narrowest solution is direct replacement and deletion, not shims or wrappers +- Bloat check (`SPEC-DISC-001`, `BLOAT-001`): PASS - the slice is explicitly subtractive and keeps broader semantics work separate +- Filament-native UI (`UI-FIL-001`): PASS - touched surfaces stay native Filament pages and actions; no custom UI framework or asset registration is needed +- Global search rule: PASS - no new searchable resource is added, and this cleanup only removes a header action from the existing findings resource rather than changing its search contract +- Panel/provider registration: PASS - Filament v5 remains on Livewire v4, and no panel or provider registration change is planned; Laravel 12 provider registration remains in `bootstrap/providers.php` if ever needed later +- Asset strategy: PASS - no new panel or shared assets are planned, so no additional `filament:assets` deploy work is introduced + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Feature for system runbook removal, tenant findings action removal, console-entry removal, and findings workflow regression; Unit or guard coverage for operation catalog, capability, triage-list, and source-scanning trace cleanup +- **Affected validation lanes**: fast-feedback, confidence, heavy-governance +- **Why this lane mix is the narrowest sufficient proof**: the business truth is server-side surface removal plus unchanged canonical findings workflow behavior. Fast-feedback and confidence cover the runtime behavior directly. One retained heavy-governance source-scanning guard is still needed because the cleanup removes an operational-control key and runbook-service entry point that the existing guard already protects from ad-hoc bypass drift. +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowRegressionTest.php` +- **Fixture / helper / factory / seed / context cost risks**: remove or collapse backfill-only fixtures, control activations, and command test setup; keep findings workflow fixtures opt-in and local to regression tests +- **Expensive defaults or shared helper growth introduced?**: no; expected net-negative because a dedicated backfill family and its source-scanning guard expectations should shrink +- **Heavy-family additions, promotions, or visibility changes**: no new heavy family is added, but one existing heavy-governance guard remains explicit in the plan because the cleanup still depends on `tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` +- **Surface-class relief / special coverage rule**: standard-native-filament and monitoring-state-page relief are sufficient; assert absence and no side effects rather than browser-only choreography +- **Closing validation and reviewer handoff**: reviewers should rerun the targeted commands, including the retained heavy-governance bypass guard, verify that no UI, CLI, deploy, control, catalog, capability, triage, trusted-state, or action-surface trace remains for `findings.lifecycle.backfill`, and confirm representative triage, assignment, progress, resolve, risk acceptance, ownership, SLA, and due-date findings flows still behave unchanged +- **Budget / baseline / trend follow-up**: expected net decrease in focused feature and guard surface +- **Review-stop questions**: did implementation leave a no-op compatibility shell, keep a hidden operation alias, preserve a dead blocked-state branch after the control key was already removed, or widen into acknowledged-status cleanup or lifecycle invariant redesign? +- **Escalation path**: reject-or-split +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: the cleanup is a bounded subtractive slice; deeper findings semantics and creation-time invariant work already have explicit follow-up candidates instead of hidden spillover + +## Project Structure + +### Documentation (this feature) + +```text +specs/253-remove-findings-backfill-runtime-surfaces/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── checklists/ +│ └── requirements.md +├── contracts/ +│ └── findings-backfill-runtime-surface-removal.contract.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Console/Commands/ +│ │ ├── TenantpilotBackfillFindingLifecycle.php +│ │ └── TenantpilotRunDeployRunbooks.php +│ ├── Filament/ +│ │ ├── Resources/FindingResource/Pages/ListFindings.php +│ │ └── System/Pages/Ops/Runbooks.php +│ ├── Jobs/ +│ │ ├── BackfillFindingLifecycleJob.php +│ │ ├── BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php +│ │ └── BackfillFindingLifecycleWorkspaceJob.php +│ ├── Services/ +│ │ ├── Runbooks/FindingsLifecycleBackfillRunbookService.php +│ │ ├── Runbooks/FindingsLifecycleBackfillScope.php +│ │ └── SystemConsole/OperationRunTriageService.php +│ └── Support/ +│ ├── Auth/PlatformCapabilities.php +│ ├── Livewire/TrustedState/TrustedStatePolicy.php +│ ├── OperationCatalog.php +│ └── Ui/ActionSurface/ActionSurfaceExemptions.php +├── database/seeders/PlatformUserSeeder.php +└── tests/ + ├── Feature/ + │ ├── Console/Spec113/DeployRunbooksCommandTest.php + │ ├── Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php + │ ├── Findings/OperationalControlFindingsBackfillGateTest.php + │ ├── OperationalControls/NoAdHocOperationalControlBypassTest.php + │ ├── System/OpsControls/OperationalControlManagementTest.php + │ └── System/OpsRunbooks/ + │ ├── FindingsLifecycleBackfillPreflightTest.php + │ ├── FindingsLifecycleBackfillStartTest.php + │ ├── OpsUxStartSurfaceContractTest.php + │ └── OperationalControlRunbookGateTest.php + └── Unit/Support/OperationalControls/OperationalControlCatalogTest.php +``` + +**Structure Decision**: Single Laravel web application. The implementation slice is subtractive and should stay inside the existing system page, tenant findings page, console command, shared runbook service, registry, and test directories instead of creating a new namespace or framework. + +## Complexity Tracking + +No constitution violations are expected. This feature should reduce permanent complexity by deleting a productized repair path, its queue jobs, and its trace surface. + +## Proportionality Review + +- **Current operator problem**: the repo still productizes a findings lifecycle repair path through runbooks, tenant findings actions, commands, operation labels, and tests even though current finding generators already write the required lifecycle fields directly +- **Existing structure is insufficient because**: a local hide or feature flag would leave the service, jobs, commands, operation labels, triage support, and backfill-only tests alive, so the product would keep advertising deleted behavior through other surfaces +- **Narrowest correct implementation**: delete the owning backfill service and job cluster, remove the UI and command entry points, remove capability and operation traces, and keep canonical findings workflows unchanged with targeted regression proof +- **Ownership cost created**: negative; maintenance burden, suite cost, and operator confusion should all decrease +- **Alternative intentionally rejected**: compatibility shims, no-op deploy command shells, historical alias preservation, or folding acknowledged-status cleanup and lifecycle invariant hardening into the same slice +- **Release truth**: current-release truth + +## Phase 0 — Research (output: `research.md`) + +See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/research.md` + +Goals: +- Confirm the narrowest deletion boundary across system UI, tenant UI, CLI, deploy/runtime hooks, jobs, registry traces, and test artifacts. +- Confirm that LEAN-001 requires removal over compatibility shims for the backfill service, commands, operation aliases, and historical run UX support. +- Record the partial operational-control cleanup already present in the repo so implementation removes remaining dead branches instead of reintroducing the control key. +- Keep acknowledged-status cleanup and creation-time lifecycle invariants explicitly deferred while documenting the regression contract for normal findings workflows. + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +See: +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/data-model.md` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/contracts/findings-backfill-runtime-surface-removal.contract.yaml` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md` + +Design focus: +- Remove the `Rebuild Findings Lifecycle` system runbook card, preflight, run modal, and related `OperationRun` launch UX from `Runbooks.php`. +- Remove the tenant findings header action `Backfill findings lifecycle` and its queued, paused, and `Open operation` messaging from `ListFindings.php` while leaving canonical findings workflow actions untouched. +- Delete `TenantpilotBackfillFindingLifecycle`, and delete `TenantpilotRunDeployRunbooks` if its only shipped responsibility remains lifecycle backfill. +- Delete `FindingsLifecycleBackfillRunbookService` and the dedicated workspace plus tenant jobs that exist only to support the removed runtime path. +- Remove `findings.lifecycle.backfill` traces from `PlatformCapabilities`, `PlatformUserSeeder`, `OperationCatalog`, `OperationRunTriageService`, test guards, docs, and backfill-specific feature tests. +- Remove `FindingsLifecycleBackfillScope.php`, the backfill-specific trusted-state markers in `TrustedStatePolicy.php`, and the backfill-specific action-surface evidence in `ActionSurfaceExemptions.php` so no hidden surface or helper residue still implies support. +- Treat current operational-control residue as cleanup input: the control catalog already rejects `findings.lifecycle.backfill`, so remaining blocked-start branches and tests should be removed rather than normalized. +- Keep historical `OperationRun` and `AuditLog` rows as tolerated legacy data without adding alias layers, migrations, or new UI promises. + +## Phase 1 — Agent Context Update + +After Phase 1 artifacts are generated, update Copilot context from the plan: + +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot` + +## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`) + +- Remove the system runbook entry points and the tenant findings header action for lifecycle backfill. +- Delete the dedicated CLI and deploy/runtime command entry points for lifecycle backfill. +- Delete the shared runbook service and the dedicated workspace or tenant backfill jobs. +- Remove capability, seeder, operation-catalog, and system-console triage traces for `findings.lifecycle.backfill`. +- Rewrite or delete backfill-only tests and docs, then add narrow absence plus regression coverage that proves the path stays gone while canonical findings workflows still work. +- Verify no compatibility shim, no-op command shell, or replacement repair path survives the cleanup. + +## Constitution Check (Post-Design) + +Re-check target: PASS. The post-design shape must still be net-negative complexity, contain no new persistence or abstraction, preserve Livewire v4 plus Filament v5 conventions, leave provider registration unchanged in `bootstrap/providers.php`, keep global search behavior unchanged, and keep the validation burden inside fast-feedback and confidence plus one explicit retained heavy-governance bypass guard only. diff --git a/specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md b/specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md new file mode 100644 index 00000000..a1d7cb67 --- /dev/null +++ b/specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md @@ -0,0 +1,36 @@ +# Quickstart — Remove Findings Lifecycle Backfill Runtime Surfaces + +## Prereqs + +- Docker running +- Laravel Sail dependencies installed +- Existing platform-operator and tenant-user factories available for targeted tests +- Existing findings workflow fixtures available for regression coverage + +## Run locally + +- Start containers: `cd apps/platform && ./vendor/bin/sail up -d` +- No schema change is expected, but use the normal repo baseline before running tests: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan migrate --no-interaction` +- Run targeted tests after implementation: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowRegressionTest.php` +- Format after implementation: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## Manual smoke after implementation + +1. Sign in to `/system` as a platform operator and confirm `/system/ops/runbooks` no longer shows `Rebuild Findings Lifecycle`, its preflight action, or its run modal. +2. Sign in to `/admin/t/{tenant}/findings` as an entitled tenant operator and confirm there is no `Backfill findings lifecycle` header action while canonical findings workflow actions still render according to current capability rules. +3. Open `/system/ops/controls` and confirm there is no findings lifecycle backfill control row, action, or history affordance. +4. Check the supported Artisan command catalog and confirm `tenantpilot:findings:backfill-lifecycle` is gone, and `tenantpilot:run-deploy-runbooks` is also gone if backfill was its only remaining shipped responsibility. +5. Exercise representative findings actions such as `Triage`, `Start progress`, `Assign`, `Resolve`, and `Risk accept` and confirm the existing workflow behavior is unchanged. +6. Open Monitoring or Operations and confirm no supported surface can create a new `findings.lifecycle.backfill` run; historical rows, if any remain in local data, must not receive new special retry or cancel affordances. + +## Notes + +- Filament v5 remains on Livewire v4.0+ in this repo; the cleanup stays inside existing native Filament pages and actions. +- No panel or provider registration changes are planned; `bootstrap/providers.php` remains the authoritative location if any provider work is ever needed later. +- No new global-search resource, searchable surface, or global-search contract change is involved. +- No new asset pipeline work is expected, so there is no added `filament:assets` deployment step. +- LEAN-001 applies directly: the cleanup should delete obsolete runtime surfaces rather than keeping aliases, no-op command shells, or compatibility branches for historical data. \ No newline at end of file diff --git a/specs/253-remove-findings-backfill-runtime-surfaces/research.md b/specs/253-remove-findings-backfill-runtime-surfaces/research.md new file mode 100644 index 00000000..665fa675 --- /dev/null +++ b/specs/253-remove-findings-backfill-runtime-surfaces/research.md @@ -0,0 +1,153 @@ +# Research — Remove Findings Lifecycle Backfill Runtime Surfaces + +**Date**: 2026-04-28 +**Spec**: [spec.md](spec.md) + +This document records the repo-grounded planning decisions for the findings lifecycle backfill cleanup slice. All decisions assume the current pre-production LEAN-001 posture. + +## Decision 1 — Remove source traces, not only visible buttons + +**Decision**: Delete the owning runtime sources for findings lifecycle backfill wherever the repo still starts, labels, or advertises the path. Do not treat the work as a page-local hide of the runbook card or the tenant findings header action. + +**Rationale**: +- The same path is currently sourced from `Runbooks.php`, `ListFindings.php`, `TenantpilotBackfillFindingLifecycle`, `TenantpilotRunDeployRunbooks`, `FindingsLifecycleBackfillRunbookService`, dedicated jobs, `OperationCatalog`, and `OperationRunTriageService`. +- The product-truth problem is cross-surface. Hiding only the visible buttons would leave CLI, deploy/runtime, catalog, and monitoring traces alive. +- FR-253-013 requires removing the source trace when a shared registry or helper family still emits lifecycle-backfill semantics. + +**Evidence**: +- `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php` +- `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php` +- `apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php` +- `apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php` +- `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php` +- `apps/platform/app/Support/OperationCatalog.php` +- `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php` + +**Alternatives considered**: +- Hide the system runbook only. + - Rejected: tenant UI, CLI, deploy/runtime, and monitoring traces would still advertise supported behavior. +- Hide the tenant findings action only. + - Rejected: `/system` and runtime hooks would still keep the repair path productized. + +## Decision 2 — Delete the backfill-only runtime cluster; do not keep no-op compatibility shells + +**Decision**: Delete `TenantpilotBackfillFindingLifecycle`, delete `TenantpilotRunDeployRunbooks` if lifecycle backfill is still its only shipped responsibility, and delete the dedicated backfill service and jobs instead of leaving dormant compatibility shells. + +**Rationale**: +- LEAN-001 explicitly prefers replacement or deletion over shims in this repo. +- `TenantpilotRunDeployRunbooks` currently delegates only to the shared backfill service, so leaving it behind as a no-op would preserve false product truth. +- The dedicated workspace and tenant job chain exists only for lifecycle backfill and has no independent product purpose after cleanup. + +**Evidence**: +- `apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php` +- `apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php` +- `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php` +- `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php` +- `apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php` +- `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php` + +**Alternatives considered**: +- Keep the commands as deprecated wrappers that print a skip message. + - Rejected: still productizes the removed path. +- Leave the service and jobs behind behind an always-false gate. + - Rejected: dead runtime ballast is exactly what this cleanup is intended to remove. + +## Decision 3 — Preserve canonical findings workflows; defer deeper semantics cleanup + +**Decision**: Keep canonical findings workflow behavior unchanged and limit this slice to removing the backfill path and any direct references that disappear with it. Continue to treat acknowledged-status cleanup and creation-time lifecycle invariants as explicit follow-up candidates. + +**Rationale**: +- `spec-candidates.md` separates `Remove Findings Lifecycle Backfill Runtime Surfaces`, `Remove Legacy Acknowledged Finding Status Compatibility`, and `Enforce Creation-Time Finding Invariants` into distinct follow-up slices. +- The current backfill jobs mutate more than surface wiring: they normalize legacy `acknowledged` to `triaged`, fill lifecycle fields, fill SLA fields, and consolidate drift duplicates. Folding those semantics into this cleanup would widen scope beyond “remove shipped repair tooling”. +- The spec and approval rubric both require a bounded cleanup slice. + +**Evidence**: +- `docs/product/spec-candidates.md` +- `docs/product/implementation-ledger.md` +- `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php` +- `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php` + +**Alternatives considered**: +- Merge acknowledged-status cleanup into this slice. + - Rejected: deeper workflow, badge, query, and RBAC consequences deserve their own bounded spec. +- Merge creation-time invariant hardening into this slice. + - Rejected: generator and reopen semantics hardening is broader than runtime-surface deletion and should follow after repair tooling is gone. + +## Decision 4 — Treat operational-control backfill traces as partial residue, not active product truth + +**Decision**: Remove remaining operational-control-related lifecycle-backfill branches and tests rather than trying to make the control path “consistent again”. + +**Rationale**: +- The repo already partially removed the operational-control surface for this path. `OperationalControlCatalogTest` rejects `findings.lifecycle.backfill`, and `OperationalControlManagementTest` asserts the controls page no longer renders it. +- The backfill service and some feature tests still carry `OperationalControlBlockedException` handling and blocked-start audit expectations for the removed control key. +- Re-adding the control key would widen product truth in the wrong direction. + +**Evidence**: +- `apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php` +- `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php` +- `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php` +- `apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php` +- `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php` + +**Alternatives considered**: +- Reintroduce `findings.lifecycle.backfill` to the operational-control catalog so all traces line up. + - Rejected: that would reverse an already-desirable cleanup and keep the non-shipping feature alive. + +## Decision 5 — Historical `OperationRun` and audit rows remain tolerated legacy data without new aliases + +**Decision**: Historical `operation_runs.type = findings.lifecycle.backfill` rows and prior audit rows may remain stored, but the cleanup must not add new alias handling, new UI guarantees, or special retry or cancel semantics solely for those historical rows. + +**Rationale**: +- LEAN-001 forbids compatibility layers without production data pressure. +- `OperationRunTriageService` still treats `findings.lifecycle.backfill` as retryable and cancelable. That support is part of the shipped runtime story and should disappear with the runtime path rather than being preserved for historical records. +- The spec explicitly says historical data migration and historical compatibility handling are out of scope. + +**Evidence**: +- `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php` +- `apps/platform/app/Support/OperationCatalog.php` +- `specs/253-remove-findings-backfill-runtime-surfaces/spec.md` + +**Alternatives considered**: +- Preserve the operation type and alias so old runs keep a polished label forever. + - Rejected: adds a compatibility obligation for non-shipping behavior. +- Add a migration to scrub old rows. + - Rejected: out of scope and not justified in pre-production. + +## Decision 6 — Validation stays in fast-feedback and confidence lanes with absence-focused proof + +**Decision**: Replace backfill-specific start, preflight, gate, and command tests with narrow absence and regression coverage. Keep representative findings workflow regression explicit and do not add browser or heavy-governance coverage. + +**Rationale**: +- The new business truth is absence of the repair path plus continuity of canonical findings workflows. +- Existing backfill tests already prove the current path thoroughly; the replacement proof should be just as targeted, but around absence and unaffected workflows. +- Browser coverage would mostly duplicate Filament action choreography and not improve confidence on the cleanup boundaries. + +**Evidence**: +- `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php` +- `apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php` +- `apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php` +- `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php` + +**Alternatives considered**: +- Keep the existing backfill-only tests and just rename assertions. + - Rejected: they would still preserve a product contract for a deleted runtime path. +- Add browser smoke for the deleted buttons. + - Rejected: the proving purpose is server-side absence and unchanged workflow behavior, not browser choreography. + +## Decision 7 — No panel, global-search, or asset work is part of this cleanup + +**Decision**: Keep the cleanup inside existing system and tenant surfaces. Do not change Filament panel registration, do not introduce or alter global-search behavior, and do not add asset work. + +**Rationale**: +- The affected surfaces already exist and already run on Filament v5 + Livewire v4. +- The cleanup removes a header action and a system runbook card, but it does not add a new resource, page family, or asset bundle. +- Provider registration changes in `bootstrap/providers.php` or `filament:assets` deployment work would be unrelated scope growth. + +**Evidence**: +- `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php` +- `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php` +- repo conventions in `Agents.md` and `.github/copilot-instructions.md` + +**Alternatives considered**: +- Add a replacement informational page or asset-backed empty state. + - Rejected: the narrowest correct implementation is removal, not replacement UX. \ No newline at end of file diff --git a/specs/253-remove-findings-backfill-runtime-surfaces/spec.md b/specs/253-remove-findings-backfill-runtime-surfaces/spec.md new file mode 100644 index 00000000..950e0458 --- /dev/null +++ b/specs/253-remove-findings-backfill-runtime-surfaces/spec.md @@ -0,0 +1,291 @@ +# Feature Specification: Remove Findings Lifecycle Backfill Runtime Surfaces + +**Feature Branch**: `253-remove-findings-backfill-runtime-surfaces` +**Created**: 2026-04-28 +**Status**: Draft +**Input**: User description: "Prepare the Spec Kit feature for Remove Findings Lifecycle Backfill Runtime Surfaces as the smallest cleanup slice that removes visible findings lifecycle backfill runbooks, commands, tenant actions, capabilities, and deploy/runtime hooks while keeping normal findings workflows unchanged." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot still ships a findings lifecycle repair path through system runbooks, tenant findings actions, CLI commands, deploy hooks, operation labeling, and residual operational-control traces even though current finding generators already write the relevant lifecycle fields directly. +- **Today's failure**: Operators and maintainers can still encounter `Backfill findings lifecycle` and `Rebuild Findings Lifecycle` as supported product truth, and the repo still carries residual control and guard traces for `findings.lifecycle.backfill` even where the control page no longer renders a live card. That overstates the product, keeps pre-production repair tooling alive, and invites the wrong next action on findings and ops surfaces. +- **User-visible improvement**: Tenant and platform operators only see supported findings workflows and supported ops controls, not an internal repair action that should not ship. +- **Smallest enterprise-capable version**: Remove the lifecycle-backfill runtime surface end to end: system runbook exposure, tenant findings action exposure, supported CLI and deploy entry points, backfill-only execution plumbing, operation and control catalog traces, and dedicated tests or docs that only exist for this path. +- **Explicit non-goals**: No findings workflow redesign, no legacy `acknowledged` semantics cleanup beyond direct path-removal references, no new repair surface, no new backfill, no migration shim, no historical data migration, and no general refactor of findings lifecycle semantics. +- **Permanent complexity imported**: Net negative. The slice removes runbook-, command-, capability-, control-, catalog-, and test-surface complexity. The only enduring obligation is narrower regression coverage that proves the removed path stays gone while normal findings workflows still work. +- **Why now**: Specs 249 through 252 already promote the broader open candidates for customer review, governance inbox, commercial entitlements, and localization. Among the remaining open items, this is the smallest safe cleanup slice. It is narrower and safer than legacy `acknowledged` cleanup because it removes visible product ballast without rewriting canonical workflow semantics, and it should land before creation-time invariant hardening so the product stops shipping repair tooling first. +- **Why not local**: The backfill is exposed simultaneously through `/system`, tenant findings UI, CLI commands, deploy/runtime hooks, operational controls, operation catalog traces, capability seeding, and a dedicated test family. A one-file hide or feature-flag leaves product truth inconsistent and keeps drift alive in other entry points. +- **Approval class**: Cleanup +- **Red flags triggered**: Multiple micro-specs for one domain. Defense: this slice is intentionally limited to visible and runtime removal only; deeper findings semantics and creation-time invariants remain explicit follow-up candidates. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 2 | Wiederverwendung: 1 | **Gesamt: 11/12** +- **Decision**: approve + +## Selection Rationale + +- Specs 249 through 252 already exist for Customer Review Workspace, Decision-Based Governance Inbox, Commercial Entitlements and Billing-State Maturity, and Platform Localization, so those candidates are no longer the next open preparation target. +- This cleanup slice is deliberately smaller and safer than `Remove Legacy Acknowledged Finding Status Compatibility` because it removes visible repair tooling without changing canonical findings workflow semantics, query semantics, badges, or RBAC language. +- This cleanup should precede `Enforce Creation-Time Finding Invariants`, because the product should first stop shipping visible repair and runtime surfaces and then harden generators so those surfaces never need to return. +- `Cross-Tenant Compare and Promotion v1` is broader and already has an older draft spec in the repo that needs refresh rather than a new small cleanup spec. +- `External Support Desk / PSA Handoff` remains deferred because current repo docs do not define a concrete external desk or PSA target. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant + canonical-view +- **Primary Routes**: + - `/system/ops/runbooks` + - `/system/ops/controls` as a regression-proof absence and source-trace cleanup surface, not a new visible removal target + - `/admin/t/{tenant}/findings` + - supported CLI and deploy/runtime entry points that currently expose findings lifecycle backfill +- **Data Ownership**: + - Tenant-owned `Finding` records remain the canonical findings workflow truth and keep required `workspace_id` and `tenant_id` anchors; this feature introduces no new persistence and no data migration. + - Workspace- and platform-owned operational traces that exist only to launch or describe findings lifecycle backfill are removed, including runbook exposure, operation and control labels, capability seeding, and dedicated repository artifacts. + - Existing reviewable findings behavior, ownership and responsibility, SLA, and due-date truth remain unchanged and are in scope only for regression protection. +- **RBAC**: + - Tenant membership remains the isolation boundary for findings visibility and findings workflow actions. + - Platform `/system` access and surviving ops-control visibility remain governed by existing platform capabilities, but the findings lifecycle backfill-specific capability is removed rather than hidden behind an alias. + - Non-members remain deny-as-not-found and in-scope members without required capability remain forbidden on surviving actions; this cleanup must not change unrelated findings or ops authorization semantics. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: N/A - the only canonical-view surfaces in scope are platform `/system` pages, which do not inherit tenant context. The tenant findings register remains tenant-scoped and keeps its current filters and default behavior. +- **Explicit entitlement checks preventing cross-tenant leakage**: Removing findings lifecycle backfill surfaces must not weaken existing workspace or tenant entitlement checks. `/system` remains platform-only, `/admin/t/{tenant}/findings` remains tenant-scoped, and no cleanup path may leak hidden tenant identity or historical backfill state to unauthorized users. + +## 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 +- **Interaction class(es)**: header actions, system runbook launch surfaces, operation labels, operational-control cards, queued or blocked status messaging, command and deploy/runtime entry points +- **Systems touched**: system runbooks page, system operational controls page, tenant findings header actions, operation catalog and system-console triage labels, CLI command catalog, deploy/runtime hook surfaces, and dedicated test/docs artifacts +- **Existing pattern(s) to extend**: existing shared runbook, operational-control, action-surface, and operation-label families remain the only shared paths; this feature reduces them to supported product truth instead of extending them with a replacement repair flow +- **Shared contract / presenter / builder / renderer to reuse**: existing shared operation labels, shared start UX, `UiEnforcement`, operational-control catalogs, and action-surface guardrails remain authoritative for surviving operations; no new replacement contract is introduced for lifecycle backfill +- **Why the existing shared path is sufficient or insufficient**: the shared paths are sufficient for supported operations and existing findings workflow actions. They are not a reason to keep a pre-production repair task productized now that the product no longer needs to advertise or route operators into it. +- **Allowed deviation and why**: none +- **Consistency impact**: backfill-specific labels, buttons, help text, paused-state copy, capability names, operation labels, command support, and test expectations must disappear together so the repo stops telling two different stories about findings lifecycle truth. +- **Review focus**: reviewers must verify that no remaining UI, CLI, deploy/runtime, control, catalog, or repository artifact still treats findings lifecycle backfill as a supported product action. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: yes +- **Shared OperationRun UX contract/layer reused**: existing shared `OperationRun` start, toast, deep-link, dedupe, and terminal-notification contracts remain authoritative for surviving operations; this feature removes the `findings.lifecycle.backfill` operation type from those surfaces. +- **Delegated start/completion UX behaviors**: `N/A` for findings lifecycle backfill after cleanup. Queued toast, `View run` or `Open operation` links, dedupe messaging, and terminal notifications remain unchanged for other operation types. +- **Local surface-owned behavior that remains**: none for the removed findings lifecycle backfill path. +- **Queued DB-notification policy**: `N/A` for the removed path; no new policy is introduced. +- **Terminal notification path**: `N/A` for the removed path. +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: no +- **Boundary classification**: `N/A` +- **Seams affected**: `N/A` +- **Neutral platform terms preserved or introduced**: `N/A` +- **Provider-specific semantics retained and why**: `N/A` +- **Why this does not deepen provider coupling accidentally**: removing findings lifecycle backfill traces does not introduce or preserve any new provider-specific platform seam. +- **Follow-up path**: none + +## 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 | +|---|---|---|---|---|---|---| +| System ops runbooks page: remove `Rebuild Findings Lifecycle` runbook card, preflight state, and run modal | yes | Native Filament + shared runbook primitives | runbook launch, start UX, notifications | page, card, modal, action state | no | Keeps `/system` limited to supported runbooks | +| Tenant findings list: remove `Backfill findings lifecycle` header action and its queued or paused messaging | yes | Native Filament + shared action-surface primitives | header actions, operation start messaging | page, header action, modal, toast state | no | Keeps the findings register focused on canonical review workflow, not repair tooling | +| System operational controls page: keep `findings.lifecycle.backfill` absent and remove remaining control-entry residue | yes | Native Filament + shared control-card primitives | status messaging, operational control cards, audit links | page, card, action state | no | Prevents a non-shipping control key from surviving as hidden source trace or stale product truth | + +## 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 | +|---|---|---|---|---|---|---|---| +| System operational controls page | Primary Decision Surface | Decide which supported runtime controls remain manageable | only supported controls, their state, and their scope | audit history for supported controls | Primary because the page still owns runtime-control decisions for live features; this slice removes one non-productized entry from that decision set | Keeps runtime-control decisions tied to shipping operations only | Removes a false pause or resume decision for a feature that should not ship | +| System ops runbooks page | Secondary Context Surface | Decide whether a supported runbook should start | only supported runbooks and their current readiness | remaining run detail and history | Not primary for findings lifecycle anymore because the repair path is removed rather than redirected | Keeps `/system` focused on real platform operations | Removes a dead-end repair option and its supporting copy | +| Tenant findings list | Primary Decision Surface | Review findings and pick the next governance action | finding status, severity, ownership, SLA, due state, and canonical workflow actions | deeper evidence, related operations, and finding history | Primary because this is where tenant operators decide what to do with findings; repair tooling never deserved co-equal prominence | Keeps findings work aligned to triage, assignment, progress, resolve, and risk governance | Removes a maintenance action that competes with the real next action | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| System operational controls page | operator/platform, support/platform | only supported controls, current state, scope, and reason | control-change history for supported controls | linked audit detail only when opened | `Adjust supported control` | no lifecycle-backfill control row, badge, or history affordance | one control list remains the source of truth for live runtime controls | +| System ops runbooks page | operator/platform | only supported runbooks, descriptions, readiness, and latest run context | run detail after opening a supported run | raw run data only in the run detail view | `Preflight` or `Run` a supported runbook | lifecycle-backfill copy, modal, and launch affordances are absent | no parallel repair truth remains in cards, toasts, or modals | +| Tenant findings list | operator/MSP | findings status, ownership, due signals, and canonical workflow actions | finding history, related operations, and review context | raw/support detail remains in existing evidence and detail surfaces | `Triage` or another canonical findings workflow action | no lifecycle-repair action or paused-state helper text | findings workflow truth stays on findings actions instead of a maintenance button | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| System operational controls page | Utility / System | Operational safety control center | Adjust a supported control | Same-page control card or modal | forbidden | Card-level secondary links only | Confirmation-protected card actions for supported controls only | `/system/ops/controls` | `/system/ops/controls` | system-plane scope, control scope, and reason | Operational controls / Operational control | only supported control keys remain visible | none | +| System ops runbooks page | Monitoring / Queue / Workbench | System runbook launcher | Preflight or run a supported runbook | Same-page action modal with run-detail links | forbidden | Page-level helper links and run links only | Explicit run modal for supported runbooks only | `/system/ops/runbooks` | `/system/ops/runs/{record}` | runbook scope and run history | Runbooks / Runbook | only supported runbooks are launchable | none | +| Tenant findings list | List / Table / Bulk | CRUD / List-first Resource | Open a finding for triage or another canonical workflow action | Full-row click to finding detail | required | Existing row `More` actions and header utilities | Existing workflow actions remain in their current grouped placements | `/admin/t/{tenant}/findings` | `/admin/t/{tenant}/findings/{record}` | tenant context, filters, status, and due-state signals | Findings / Finding | canonical findings workflow and governance truth | 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| System operational controls page | Platform operator | Manage supported runtime controls only | Control center | Which runtime controls are part of shipping product truth right now? | supported controls, effective state, scope, reason, expiry | audit history for supported controls | runtime safety state, scope, expiry | TenantPilot only | Pause supported control, Resume supported control | State-changing control actions for supported controls only | +| System ops runbooks page | Platform operator | Decide whether a supported runbook should start | Workbench | Is there a supported operational action to run from here? | supported runbooks, descriptions, latest run context | linked run detail after navigation | execution readiness and recent outcome | TenantPilot only unless a surviving runbook legitimately mutates tenant state | Preflight supported runbook, Run supported runbook | Run supported runbook | +| Tenant findings list | Tenant operator | Decide how to triage or manage findings without maintenance detours | List/detail | What findings action should I take next? | status, severity, responsibility, SLA, due state, canonical workflow actions | deeper evidence, related operations, review context | lifecycle, governance validity, due attention, responsibility | TenantPilot only for findings workflow actions; no repair mutation path remains | Triage, Start progress, Assign, Resolve, Risk accept | Existing destructive-like workflow actions only | + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Feature, Unit, Heavy-Governance +- **Validation lane(s)**: fast-feedback, confidence, heavy-governance +- **Why this classification and these lanes are sufficient**: The slice removes operator surfaces, supported commands, catalog traces, and dedicated test artifacts while requiring regression proof that canonical findings workflows still function unchanged. Narrow feature tests prove absence on system and tenant surfaces plus removed CLI and deploy entry points. Narrow unit coverage proves catalog, control, capability, trusted-state, and action-surface traces are gone. One retained heavy-governance source-scanning guard remains necessary because the cleanup deletes an operational-control key and its owning runbook service, and the repo already treats that bypass guard as `surface-guard` coverage. +- **New or expanded test families**: focused absence and guard coverage for removed findings lifecycle backfill surfaces and traces, plus representative findings workflow regression coverage. Backfill-only preflight, start, idempotency, operational-control, and deploy-hook test families that exist only for this path are deleted or collapsed; no new heavy-governance family is introduced. +- **Fixture / helper cost impact**: low and net-negative. The feature should remove dedicated backfill fixtures, jobs, and lane-manifest references instead of adding new heavy setup. +- **Heavy-family visibility / justification**: one existing heavy-governance guard remains explicit in `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` because the cleanup removes a control key and a runbook-service entry point that the guard already scans. The feature does not add a new heavy family; it keeps one existing guard visible instead of silently depending on it. +- **Special surface test profile**: standard-native-filament, monitoring-state-page +- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient for system and findings surfaces. Required extra proof is absence and guard coverage for CLI and catalog traces, plus regression checks for canonical findings workflow actions. +- **Reviewer handoff**: reviewers must verify removal at three layers: no system runbook or findings header action remains, no supported CLI or deploy/runtime trigger remains, and no control, capability, operation-label, or test-lane residue still advertises lifecycle backfill. They must also confirm representative triage, assignment, progress, resolve, risk-acceptance, ownership, SLA, and due-date flows still work unchanged. +- **Budget / baseline / trend impact**: expected net reduction because a dedicated backfill test family and related lane artifacts are removed. +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowRegressionTest.php` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Stop Shipping Repair Tooling (Priority: P1) + +As a platform or tenant operator, I should only see supported findings and ops actions, not a lifecycle-repair surface that the product no longer intends to ship. + +**Why this priority**: This is the primary trust and product-truth outcome. If the UI still advertises the repair path, the cleanup has failed even before deeper semantics work begins. + +**Independent Test**: Can be fully tested by opening the current system runbooks page, system operational controls page, and tenant findings list and verifying that no findings lifecycle backfill affordance remains while the ordinary findings workflow remains visible. + +**Acceptance Scenarios**: + +1. **Given** a platform operator opens the system runbooks page, **When** the page renders, **Then** there is no `Rebuild Findings Lifecycle` runbook, preflight state, or run modal. +2. **Given** a platform operator opens the system operational controls page, **When** the page renders, **Then** there is no `findings.lifecycle.backfill` control row, pause or resume affordance, or history affordance. +3. **Given** an entitled tenant operator opens the tenant findings list, **When** the page renders, **Then** there is no `Backfill findings lifecycle` header action and the existing findings workflow actions remain visible according to current capability rules. + +--- + +### User Story 2 - Remove Hidden Runtime Entry Points (Priority: P1) + +As a platform operator or deploy owner, I should not be able to trigger findings lifecycle backfill through a supported command or deploy/runtime hook once the product stops advertising the repair path. + +**Why this priority**: The cleanup is incomplete if the UI is clean but CLI or deploy surfaces still launch the same repair behavior in the background. + +**Independent Test**: Can be fully tested by checking the supported command and deploy/runtime entry points and proving that no supported path queues findings lifecycle backfill anymore. + +**Acceptance Scenarios**: + +1. **Given** the repo exposes supported maintenance commands, **When** the supported command catalog is reviewed after the cleanup, **Then** `tenantpilot:findings:backfill-lifecycle` is no longer a supported command. +2. **Given** the deploy/runtime hook path is exercised after the cleanup, **When** the hook runs, **Then** it does not queue or start findings lifecycle backfill. +3. **Given** the cleanup removes the only shipped responsibility of a generic deploy-runbooks entry point, **When** the feature lands, **Then** that entry point is removed rather than left behind as an inert compatibility shell. + +--- + +### User Story 3 - Keep Canonical Findings Workflow Unchanged (Priority: P2) + +As a tenant operator, I still need triage, assignment, in-progress, resolve, risk acceptance, ownership, SLA, due-date, and existing reviewable finding behavior to work exactly as before while the repair surface is removed. + +**Why this priority**: This slice is cleanup, not redesign. It should tighten product truth without changing the day-to-day findings workflow. + +**Independent Test**: Can be fully tested by running representative findings workflow actions after the cleanup and confirming that existing findings outcomes and reviewable behavior still work without any backfill dependency. + +**Acceptance Scenarios**: + +1. **Given** a tenant operator triages, assigns, starts progress on, resolves, or risk-accepts a finding, **When** the action succeeds, **Then** the same canonical findings workflow behavior remains intact. +2. **Given** findings already carry ownership, SLA, due-date, and reviewable context, **When** the cleanup lands, **Then** those surfaces and behaviors remain unchanged apart from the removed backfill action. +3. **Given** a reviewer uses existing finding detail and related review context, **When** the repair path is removed, **Then** no replacement repair message or workflow detour appears. + +### Edge Cases + +- Historical pre-production `OperationRun` or audit rows may still mention findings lifecycle backfill; this slice does not preserve special labels, filters, or compatibility handling for those old records. +- Removing a shared catalog or control entry must remove the source trace itself, not leave stale empty cards, headers, or hidden conditionals on system surfaces. +- If `tenantpilot:run-deploy-runbooks` exists only to launch findings lifecycle backfill, LEAN-001 forbids keeping it as a no-op compatibility shim after the backfill path is removed. +- Any test lane manifest, docs artifact, or action-surface exemption that only references findings lifecycle backfill must be cleaned in the same slice so the repo does not keep advertising deleted behavior indirectly. +- Normal findings workflow actions must remain visible and capability-gated even though one header action disappears; the cleanup must not collapse ordinary findings action hierarchy. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature introduces no new Microsoft Graph call, no new long-running work, and no new persisted truth. It removes an obsolete repair and runtime path across UI, CLI, deploy, and repository artifacts. Tenant-owned findings remain bound to required `workspace_id` and `tenant_id` anchors, and the cleanup must not imply any replacement repair flow. + +**Constitution alignment (LEAN-001 / PROP-001 / BLOAT-001):** This is explicitly a pre-production cleanup slice. No legacy alias, fallback reader, migration shim, dormant command, or historical fixture preservation is allowed for findings lifecycle backfill. The slice removes structure rather than adding it, and it keeps deeper semantics cleanup and creation-time invariant hardening as separate follow-up work. + +**Constitution alignment (XCUT-001):** The cleanup is cross-cutting across runbook launch surfaces, findings header actions, operation labels, operational controls, notifications, command support, and deploy/runtime hooks. Every one of those surfaces must converge on the same truth: findings lifecycle backfill is not a supported product action. + +**Constitution alignment (TEST-GOV-001):** Proof stays mostly in narrow feature and unit coverage, with one retained heavy-governance source-scanning guard for operational-control bypass residue. The change should still shrink, not grow, the suite by deleting backfill-only test families and lane references while keeping representative findings workflow regression protection explicit. + +**Constitution alignment (OPS-UX / OPS-UX-START-001):** This feature removes one `OperationRun` start surface rather than adding one. No replacement queued toast, run link, or terminal notification is introduced for findings lifecycle backfill, and shared start UX remains unchanged for every other operation type. + +**Constitution alignment (RBAC-UX):** Removing lifecycle backfill surfaces must not change current 404 versus 403 semantics for surviving findings or system actions. The feature removes the backfill-specific capability and its visibility path rather than creating capability aliases or a hidden bypass. + +**Constitution alignment (UI-FIL-001 / UI-NAMING-001 / DECIDE-001 / OPSURF-001):** Existing system and findings surfaces remain native operator surfaces, but non-canonical repair labels such as `Rebuild Findings Lifecycle` and `Backfill findings lifecycle` are removed from default-visible decision paths. The dominant next actions on those surfaces stay tied to supported runbooks and canonical findings workflow actions only. + +**Constitution alignment (PROV-001):** Not applicable. This cleanup does not introduce or deepen a shared provider or platform seam. + +### Functional Requirements + +- **FR-253-001**: The system MUST remove the system runbook entry labeled `Rebuild Findings Lifecycle` and its related preflight, run, last-run, and launch-copy surfaces from `/system/ops/runbooks`. +- **FR-253-002**: The system MUST remove the tenant findings header action labeled `Backfill findings lifecycle` and any related queued, paused, or `Open operation` messaging that exists only for that action. +- **FR-253-003**: The system MUST retire findings lifecycle backfill as a supported CLI path, including `tenantpilot:findings:backfill-lifecycle` and any deploy/runtime command that exists only to start the same backfill. +- **FR-253-004**: No deploy hook, runtime hook, schedule, or operational bootstrap path may start, queue, or advertise findings lifecycle backfill after this cleanup. +- **FR-253-005**: Backfill-specific jobs, runbook services, scope helpers, notification branches, and execution plumbing that exist only to support the removed runtime surfaces MUST be deleted rather than left dormant behind a flag, control gate, or compatibility stub. +- **FR-253-006**: Operation-catalog entries, operation-type aliases, system-console triage traces, operational-control keys, capability constants, seed data, and other repository traces that exist only for `findings.lifecycle.backfill` MUST be removed in the same slice. +- **FR-253-007**: Tests, lane manifests, docs, action-surface references, and repository artifacts that exist only to prove or describe findings lifecycle backfill preflight, start, pause, completion, or audit behavior MUST be removed or rewritten in the same slice. +- **FR-253-008**: The feature MUST NOT introduce a replacement repair surface, a new backfill, a migration shim, a fallback command, or a historical data migration. +- **FR-253-009**: Canonical findings workflows remain unchanged and in scope only for regression protection: triage, assignment, start progress, resolve, risk acceptance, ownership and responsibility, SLA, due-date, and existing reviewable finding behavior continue to work under current authorization, audit, and lifecycle rules. +- **FR-253-010**: Legacy `acknowledged` status cleanup remains out of scope except where the removed backfill path still references it directly; the broader semantics collapse remains a separate follow-up spec. +- **FR-253-011**: Creation-time lifecycle readiness remains a follow-up hardening concern; the product must not answer that concern by leaving a visible repair path or implying that a future backfill will return. +- **FR-253-012**: Tenant-owned findings keep existing `workspace_id` and `tenant_id` ownership anchors; no new persisted alias, compatibility row, or auxiliary repair truth is introduced to preserve the removed behavior. +- **FR-253-013**: If a shared UI or runtime surface currently exposes findings lifecycle backfill only because it derives from a shared catalog or registry, the cleanup MUST remove the source trace rather than hiding the entry with a local condition. + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| System ops runbooks page | `app/Filament/System/Pages/Ops/Runbooks.php` | Existing header actions remain only for supported runbooks; findings lifecycle backfill actions are removed | Same-page runbook cards and run-detail links for surviving runbooks | none | none | none | none | run modals remain only for supported runbooks | unchanged for surviving runbooks | This slice removes the lifecycle-backfill card, preflight, and run flow instead of adding a replacement action | +| System operational controls page | `app/Filament/System/Pages/Ops/Controls.php` | Existing control-management actions remain only for supported controls; no findings lifecycle backfill control actions remain | Same-page control cards for supported controls | none | none | none | same-page control actions only | same-page control modals for supported controls only | unchanged for surviving controls | This slice removes the lifecycle-backfill control entry instead of hiding it behind an exception | +| Tenant findings list | `app/Filament/Resources/FindingResource/Pages/ListFindings.php` | Existing findings workflow utilities remain; `Backfill findings lifecycle` is removed | Clickable row to finding detail remains unchanged | existing row actions unchanged | existing grouped bulk actions unchanged | existing empty-state behavior unchanged | existing finding detail header actions unchanged | `N/A` | unchanged for surviving findings workflow actions | The cleanup removes one non-canonical header action and leaves the existing findings workflow action hierarchy intact | + +### Key Entities *(include if feature involves data)* + +- **Findings lifecycle backfill surface**: Any supported operator-visible or supported invocation path that starts or advertises the removed repair flow, including system runbooks, tenant findings actions, supported commands, deploy hooks, control entries, and operation labels. +- **Canonical findings workflow**: The existing tenant-owned findings lifecycle and governance actions that remain the only supported path for triage, assignment, in-progress, resolve, risk acceptance, ownership, SLA, due-date, and reviewable finding behavior. +- **Backfill-only artifact**: A repository artifact such as a job, service, control key, capability constant, test, lane manifest, or doc that exists only to support or describe the removed findings lifecycle backfill path. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: System and tenant product surfaces expose zero visible launch affordances for findings lifecycle backfill after the cleanup. +- **SC-002**: Supported CLI, deploy, and runtime entry points expose zero supported paths that queue or start findings lifecycle backfill after the cleanup. +- **SC-003**: Operational-control and operation-label surfaces expose zero live product traces of `findings.lifecycle.backfill` after the cleanup. +- **SC-004**: Representative findings workflow regression validation continues to pass for triage, assignment, start progress, resolve, risk acceptance, ownership and responsibility, SLA, due-date, and existing reviewable finding behavior without any repair-tool dependency. +- **SC-005**: The dedicated backfill-only test and lane footprint decreases rather than grows as part of the cleanup slice. + +## Dependencies + +- Current findings generators already create lifecycle-ready records for normal product paths, even though invariant hardening remains a follow-up. +- Existing findings workflow and reviewable findings behavior remain the canonical operator truth described by current findings specs and runtime surfaces. +- Existing system runbook, operational-control, operation-catalog, and capability registries are the current places where lifecycle-backfill traces must be removed consistently. + +## Assumptions + +- LEAN-001 still applies because the product remains pre-production; historical findings lifecycle backfill rows, fixtures, or aliases do not justify compatibility behavior. +- Specs 249 through 252 already cover the broader open candidates for customer review, governance inbox, commercial entitlements, and localization, so this cleanup is the next best open small slice. +- `Cross-Tenant Compare and Promotion v1` should return later by refreshing its older draft spec rather than being recast as a new cleanup slice here. +- `External Support Desk / PSA Handoff` remains deferred until repo docs name a concrete external desk or PSA target. + +## Risks + +- Hidden references may still exist outside the named anchors, especially in lane manifests, docs, audit helpers, or shared catalog-derived UI surfaces. +- Removing shared catalog or capability traces too narrowly could leave stale empty cards, filters, or labels that continue to imply support for the removed path. +- If any current findings generator still depends on repair behavior implicitly, removing the visible runtime path will expose that gap immediately and will need the follow-up invariant-hardening spec rather than a reintroduced backfill. + +## Out of Scope + +- Removing legacy `acknowledged` workflow semantics beyond direct references that disappear with the backfill path +- Redesigning findings workflow actions, ownership semantics, SLA behavior, due-date semantics, or reviewable finding behavior +- Introducing a replacement repair surface, a new lifecycle backfill, or any migration shim +- Historical data migration, legacy alias preservation, or compatibility-specific handling for old backfill runs +- Customer Review Workspace, Decision-Based Governance Inbox, Commercial Entitlements and Billing-State Maturity, Localization, and broader cross-tenant compare work + +## Follow-up Candidates + +1. `Remove Legacy Acknowledged Finding Status Compatibility` for the deeper workflow, badge, filter, and RBAC cleanup that should happen only after visible repair and runtime surfaces are gone. +2. `Enforce Creation-Time Finding Invariants` to prove new findings are lifecycle-ready at write time so the removed repair surface never needs to return. +3. `Cross-Tenant Compare and Promotion v1` as a refreshed broader portfolio-action spec from the older draft already in the repo, not as part of this cleanup slice. +4. `External Support Desk / PSA Handoff` once repo docs define a concrete external desk target and bounded integration contract. diff --git a/specs/253-remove-findings-backfill-runtime-surfaces/tasks.md b/specs/253-remove-findings-backfill-runtime-surfaces/tasks.md new file mode 100644 index 00000000..ef99974f --- /dev/null +++ b/specs/253-remove-findings-backfill-runtime-surfaces/tasks.md @@ -0,0 +1,231 @@ +# Tasks: Remove Findings Lifecycle Backfill Runtime Surfaces + +**Input**: Design documents from `/specs/253-remove-findings-backfill-runtime-surfaces/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/findings-backfill-runtime-surface-removal.contract.yaml` + +**Tests (TEST-GOV-001)**: REQUIRED (Pest). Keep proof in the targeted `fast-feedback` and `confidence` feature + unit lanes already named in `specs/253-remove-findings-backfill-runtime-surfaces/plan.md` and `specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md`, plus one retained `heavy-governance` guard in `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` because the cleanup removes an operational-control key and its owning runbook-service seam. Prefer absence-focused coverage in `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php`, `apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php`, `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`, `apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php`, `apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php`, `apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php`, and `apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php`. Keep the cleanup net-negative by deleting backfill-only tests instead of widening the suite. +**Operations**: This slice removes one existing `OperationRun` start family. Do not add a replacement runbook, alias, no-op command shell, or local start UX. Historical `operation_runs` and `audit_logs` rows may remain untouched, but no supported surface may create a new `findings.lifecycle.backfill` run after cleanup. +**RBAC**: Preserve current `/system` platform-only access, `/admin/t/{tenant}` tenant isolation, deny-as-not-found `404` for non-members or out-of-scope users, and `403` for in-scope capability failures on surviving actions. Remove the backfill-specific platform capability constant and seed grant without widening any unrelated authorization behavior. +**UI / Surface Guardrails**: This is a `review-mandatory` cleanup across native Filament system runbooks, native Filament system operational controls, and the tenant findings list. Keep `standard-native-filament` relief for surviving surfaces, remove the backfill affordances entirely, and do not introduce replacement helper copy, new panels, or new assets. +**Filament UI Action Surfaces**: No new Filament Resource, Page, RelationManager, panel, or provider work is introduced. `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php`, `apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php`, and `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php` must converge on supported runbook and findings workflow actions only. +**Organization**: Tasks are grouped by user story so each slice stays independently verifiable. Recommended delivery order is Phase 1 -> Phase 2 -> `US1` and `US2` in parallel -> `US3` -> final cleanup and validation, because regression proof only matters after all backfill start seams and traces are removed. + +## Test Governance Checklist + +- [ ] Lane assignment stays `fast-feedback` plus `confidence`, with one explicit retained `heavy-governance` guard for operational-control bypass residue, and remains the narrowest sufficient proof for the removed runtime family. +- [ ] New or changed tests stay in focused `Feature` and `Unit` files only; no browser or new heavy-governance family is added. +- [ ] Shared helpers, factories, seeds, fixtures, and support defaults remain cheap by default; any backfill-specific setup is deleted instead of generalized. +- [ ] Planned validation commands stay limited to the targeted Sail test commands already captured in `specs/253-remove-findings-backfill-runtime-surfaces/plan.md` and `specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md`. +- [ ] The declared surface test profile stays `standard-native-filament` plus `monitoring-state-page` where the system runbooks or controls surfaces need explicit absence proof. +- [ ] Any material suite-footprint or follow-up note resolves in this feature as `document-in-feature` or `follow-up-spec`, not as an implicit scope expansion. + +## Phase 1: Setup (Shared Cleanup Anchors) + +**Purpose**: Lock the concrete removal inventory and proving commands before implementation starts. + +- [ ] T001 [P] Verify the source-surface inventory across `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php`, `apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php`, `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`, `apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php`, and `apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php` +- [ ] T002 [P] Verify the runtime-cluster and trace inventory across `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php`, `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillScope.php`, `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php`, `apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php`, `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php`, `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Support/Auth/PlatformCapabilities.php`, `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php`, `apps/platform/app/Support/Livewire/TrustedState/TrustedStatePolicy.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, and `apps/platform/database/seeders/PlatformUserSeeder.php` +- [ ] T003 [P] Verify the narrow validation-lane commands and manual smoke expectations in `specs/253-remove-findings-backfill-runtime-surfaces/plan.md` and `specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md` + +**Checkpoint**: The cleanup boundaries and proving commands are locked before any runtime file is changed. + +--- + +## Phase 2: Foundational (Blocking Proof Surfaces) + +**Purpose**: Make the absence proof, regression anchors, and cleanup inventory explicit before deleting shared runtime seams. + +**CRITICAL**: No user story work should begin until this phase is complete. + +- [ ] T004 [P] Lock the surface-removal proof plan across `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php`, `apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php`, `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`, and `apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php` +- [ ] T005 [P] Lock the registry, capability, and retained heavy-governance bypass proof plan across `apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php`, `apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php`, `apps/platform/tests/Feature/System/Spec114/OpsTriageActionsTest.php`, and `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` +- [ ] T006 [P] Audit canonical findings workflow and authorization regression anchors across `apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php`, `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php` +- [ ] T007 [P] Verify the backfill-only cleanup targets across `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php`, `apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php`, `apps/platform/tests/Feature/Findings/FindingBackfillTest.php`, `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php`, `apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php`, and `docs/HANDOVER.md` + +**Checkpoint**: Absence-proof files, regression anchors, and cleanup-only artifacts are explicit and ready for bounded implementation work. + +--- + +## Phase 3: User Story 1 - Stop Shipping Repair Tooling (Priority: P1) 🎯 MVP + +**Goal**: Remove the visible lifecycle-backfill affordances from system and tenant operator surfaces so the product only presents supported findings and ops actions. + +**Independent Test**: Open `/system/ops/runbooks`, `/system/ops/controls`, and `/admin/t/{tenant}/findings` and verify there is no lifecycle-backfill card, control trace, or header action while the surviving findings workflow actions still render under current authorization rules. + +### Tests for User Story 1 + +- [ ] T008 [P] [US1] Add system runbook absence coverage for the removed card, preflight state, modal, and last-run copy in `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php` +- [ ] T009 [P] [US1] Add tenant findings absence coverage for the removed header action and backfill-only `Open operation` messaging in `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php` +- [ ] T010 [P] [US1] Add system operational-control absence coverage for removed `findings.lifecycle.backfill` surface traces in `apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php` + +### Implementation for User Story 1 + +- [ ] T011 [US1] Remove the lifecycle-backfill runbook card, preflight action, run modal, and last-run display from `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php` and `apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php` +- [ ] T012 [US1] Remove the tenant findings lifecycle-backfill header action and its backfill-only queued, paused, and link copy from `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php` +- [ ] T013 [US1] Reconcile surface-level authorization continuity for surviving system and tenant actions in `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php`, `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`, `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php` + +**Checkpoint**: User Story 1 is independently functional and the visible repair tooling is gone from system and tenant operator surfaces. + +--- + +## Phase 4: User Story 2 - Remove Hidden Runtime Entry Points (Priority: P1) + +**Goal**: Remove every supported command, deploy hook, runtime service, job, and shared registry trace that can still start or advertise findings lifecycle backfill. + +**Independent Test**: Review the supported command surface, shared runtime seams, operation catalog, triage helpers, capability registry, and seeder grants and verify that no supported path can queue or describe `findings.lifecycle.backfill` anymore. + +### Tests for User Story 2 + +- [ ] T014 [P] [US2] Add command-removal coverage for the missing lifecycle-backfill CLI and deploy entry points in `apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php` +- [ ] T015 [P] [US2] Add operation-catalog and capability trace removal guards in `apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php` and `apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php` +- [ ] T016 [P] [US2] Add operational-control, triage, and retained bypass-guard residue coverage for removed backfill traces in `apps/platform/tests/Feature/System/Spec114/OpsTriageActionsTest.php`, `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`, and `apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php` + +### Implementation for User Story 2 + +- [ ] T017 [US2] Delete the supported CLI and deploy/runtime entry points in `apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php` and `apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php` +- [ ] T018 [US2] Delete the dedicated backfill runtime cluster in `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php`, `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillScope.php`, `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php`, `apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php`, and `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php` +- [ ] T019 [US2] Remove `findings.lifecycle.backfill` registry, authorization, and trusted-state traces from `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php`, `apps/platform/app/Support/Auth/PlatformCapabilities.php`, `apps/platform/app/Support/Livewire/TrustedState/TrustedStatePolicy.php`, and `apps/platform/database/seeders/PlatformUserSeeder.php` +- [ ] T020 [US2] Remove or rewrite backfill-only command, control, and action-surface expectations in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, `apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php`, `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php`, and `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` + +**Checkpoint**: User Story 2 is independently functional and no supported runtime or registry path can start or advertise lifecycle backfill. + +--- + +## Phase 5: User Story 3 - Keep Canonical Findings Workflow Unchanged (Priority: P2) + +**Goal**: Preserve canonical findings workflow behavior and authorization semantics while the repair path is removed. + +**Independent Test**: Run representative triage, assignment, start progress, resolve, and risk-accept flows after the cleanup and confirm the same tenant isolation plus `404` versus `403` semantics still hold for surviving findings and system surfaces. + +### Tests for User Story 3 + +- [ ] T021 [P] [US3] Add representative findings workflow regression coverage for triage, assignment, start progress, resolve, risk acceptance, ownership, SLA, due-date, and reviewable continuity in `apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php` +- [ ] T022 [P] [US3] Add explicit surviving-surface authorization regression assertions inside `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php`, `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php` + +### Implementation for User Story 3 + +- [ ] T023 [US3] Reconcile surviving findings workflow fixtures and assertions so they no longer depend on deleted backfill helpers in `apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php`, `apps/platform/tests/Feature/Findings/FindingBackfillTest.php`, and `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php` + +**Checkpoint**: User Story 3 is independently functional and the canonical findings workflow remains unchanged after the backfill cleanup. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Remove backfill-only repository residue, keep docs and lane support honest, and run the narrow validation workflow. + +- [ ] T024 [P] Remove or rewrite backfill-only runbook test families in `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php`, and `apps/platform/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php` +- [ ] T025 [P] Remove or rewrite backfill-only findings and control test artifacts in `apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php`, `apps/platform/tests/Feature/Findings/FindingBackfillTest.php`, `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php`, `apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php`, and `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php` +- [ ] T026 [P] Clean remaining handover and lane-support traces in `docs/HANDOVER.md`, `apps/platform/tests/Support/TestLaneManifest.php`, `scripts/platform-test-lane`, and `scripts/platform-test-report` +- [ ] T027 Run formatting for touched PHP files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` after the cleanup across `apps/platform/app/`, `apps/platform/tests/`, and `apps/platform/database/seeders/PlatformUserSeeder.php` +- [ ] T028 [P] Run the targeted surface-removal Pest command from `specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md` against `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php`, `apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php`, `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`, and `apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php` +- [ ] T029 [P] Run the targeted registry and workflow Pest commands from `specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md` against `apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php`, `apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php`, and `apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php` +- [ ] T030 Run final residue searches for `findings.lifecycle.backfill`, `Backfill findings lifecycle`, `Rebuild Findings Lifecycle`, `FindingsLifecycleBackfill`, and `TenantpilotBackfillFindingLifecycle` across `apps/platform/app/`, `apps/platform/resources/`, `apps/platform/tests/`, `apps/platform/database/`, and `docs/HANDOVER.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: Starts immediately and locks the concrete inventory plus validation commands. +- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until proof files, regression anchors, and cleanup-only artifacts are explicit. +- **User Story 1 (Phase 3)**: Depends on Foundational and is part of the MVP delivery. +- **User Story 2 (Phase 4)**: Depends on Foundational and can proceed in parallel with User Story 1 because it targets the hidden runtime and registry seams behind the removed surfaces. +- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because workflow regression only proves the right thing once every backfill start surface is gone. +- **Polish (Phase 6)**: Depends on all desired user stories being complete so backfill-only tests, docs, and residue searches can be cleaned once. + +### User Story Dependencies + +- **US1**: No dependencies beyond Foundational. +- **US2**: No dependencies beyond Foundational. +- **US3**: Depends on US1 and US2. + +### Within Each User Story + +- Add or update the story tests first and confirm they fail before cleanup edits are considered complete. +- Remove source traces instead of hiding the backfill path locally on one page. +- Do not keep compatibility aliases, no-op commands, replacement repair surfaces, or historical row UX promises. +- Keep acknowledged-status cleanup and creation-time lifecycle invariant hardening out of scope for this feature. + +### Parallel Opportunities + +- `T001`, `T002`, and `T003` can run in parallel during Setup. +- `T004`, `T005`, `T006`, and `T007` can run in parallel during Foundational work. +- `T008`, `T009`, and `T010` can run in parallel for User Story 1, followed by `T011` and `T012`, before reconciling continuity in `T013`. +- `T014`, `T015`, and `T016` can run in parallel for User Story 2, followed by `T017`, `T018`, and `T019`, before reconciling backfill-only expectations in `T020`. +- User Story 1 and User Story 2 can proceed in parallel after Foundational is complete. +- `T024`, `T025`, and `T026` can run in parallel during cross-cutting cleanup. +- `T028` and `T029` can run in parallel during final validation. + +--- + +## Parallel Example: User Story 1 + +```bash +# User Story 1 tests in parallel +T008 apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php +T009 apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php +T010 apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php + +# User Story 1 implementation after the tests are in place +T011 apps/platform/app/Filament/System/Pages/Ops/Runbooks.php + apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php +T012 apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php +``` + +## Parallel Example: User Story 2 + +```bash +# User Story 2 tests in parallel +T014 apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php +T015 apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php + apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php +T016 apps/platform/tests/Feature/System/Spec114/OpsTriageActionsTest.php + apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php + apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php + +# User Story 2 implementation after the tests are in place +T017 apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php + apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php +T018 apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php + apps/platform/app/Jobs/BackfillFindingLifecycle*.php +T019 apps/platform/app/Support/OperationCatalog.php + apps/platform/app/Services/SystemConsole/OperationRunTriageService.php + apps/platform/app/Support/Auth/PlatformCapabilities.php + apps/platform/database/seeders/PlatformUserSeeder.php +``` + +## Parallel Example: Cross-Story Delivery After Foundational + +```bash +# Visible surfaces and hidden runtime traces can be removed in parallel after Phase 2 +T011-T013 apps/platform/app/Filament/System/Pages/Ops/Runbooks.php + apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php + apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php + related surface tests +T017-T020 apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php + apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php + apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php + apps/platform/app/Jobs/BackfillFindingLifecycle*.php + apps/platform/app/Support/OperationCatalog.php + apps/platform/app/Services/SystemConsole/OperationRunTriageService.php + apps/platform/app/Support/Auth/PlatformCapabilities.php + apps/platform/database/seeders/PlatformUserSeeder.php +``` + +--- + +## Implementation Strategy + +### MVP First (User Stories 1 and 2) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Complete Phase 4: User Story 2. +5. Run `T027`, `T028`, and `T030` before widening into workflow-regression cleanup. + +### Incremental Delivery + +1. Lock the removal inventory and proving commands. +2. Remove the visible runbook, findings action, and control-surface traces. +3. Remove the hidden CLI, deploy-hook, service, job, capability, catalog, and triage seams. +4. Prove the canonical findings workflow and authorization semantics still behave the same. +5. Clean backfill-only tests and docs, then finish with Pint plus the targeted Pest commands. + +### Parallel Team Strategy + +1. One contributor can own the visible surface cleanup (`US1`) while another owns the command, runtime, and registry cleanup (`US2`) after Phase 2. +2. Once both P1 stories land, a focused pass can own the workflow-regression slice (`US3`) without reopening runtime-surface decisions. +3. A final pass can remove backfill-only test or docs residue and run the narrow validation commands. + +--- + +## Notes + +- Suggested MVP scope: Phase 1 through Phase 4 only. Visible-surface removal without runtime-cluster removal is not sufficient for this feature. +- Explicit non-goals for implementation remain: legacy `acknowledged` status cleanup, creation-time lifecycle invariant hardening, a replacement repair surface, historical data migration, and compatibility aliases or no-op command shells. +- Follow-up candidates remain the same as the prepared spec: `Remove Legacy Acknowledged Finding Status Compatibility` and `Enforce Creation-Time Finding Invariants`. +- All tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and concrete file paths. \ No newline at end of file -- 2.45.2 From 54fb65a63a96178d325e3465eb3b8b01ee483cda Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 29 Apr 2026 07:50:16 +0000 Subject: [PATCH 28/36] chore: promote platform-dev to dev (#297) This pull request promotes the current state of `platform-dev` to the main integration branch `dev`. It includes recent features, fixes, and architectural refinements validated on the platform development track. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/297 --- .../Commands/PurgeLegacyBaselineGapRuns.php | 32 +- .../Pages/Reviews/CustomerReviewWorkspace.php | 15 + .../Filament/Resources/FindingResource.php | 5 - .../FindingResource/Pages/ListFindings.php | 1 - .../Filament/System/Pages/Ops/Controls.php | 5 - apps/platform/app/Models/Finding.php | 28 +- apps/platform/app/Policies/FindingPolicy.php | 5 +- .../app/Services/Auth/RoleCapabilityMap.php | 3 - .../Findings/FindingWorkflowService.php | 18 +- .../TenantReviewSectionFactory.php | 2 +- .../app/Support/Auth/Capabilities.php | 2 - .../Baselines/BaselineCompareStats.php | 1 - .../Concerns/DerivesWorkspaceIdFromTenant.php | 2 +- .../Support/Filament/FilterOptionCatalog.php | 24 +- .../platform/app/Support/OperationCatalog.php | 10 + .../database/factories/FindingFactory.php | 12 - .../contextual-help.blade.php | 15 +- .../Browser/OnboardingDraftRefreshTest.php | 20 +- .../OnboardingDraftVerificationResumeTest.php | 46 +-- .../RemoveAcknowledgedCapabilityAliasTest.php | 23 ++ .../BaselineCaptureAuditEventsTest.php | 3 +- ...aselineSnapshotNoTenantIdentifiersTest.php | 2 +- .../CaptureBaselineContentTest.php | 3 +- ...CaptureBaselineFullContentOnDemandTest.php | 3 +- .../CaptureBaselineMetaFallbackTest.php | 2 +- ...BaselineCaptureRbacRoleDefinitionsTest.php | 3 +- .../Feature/Baselines/BaselineCaptureTest.php | 27 +- ...elineCompareMatrixCompareAllActionTest.php | 7 +- .../Feature/DirectoryGroups/StartSyncTest.php | 3 +- .../SyncJobUpsertsGroupsTest.php | 3 +- .../SyncRetentionPurgeTest.php | 3 +- .../EntraAdminRolesFindingGeneratorTest.php | 13 +- ...BaselineCompareLandingStartSurfaceTest.php | 6 +- ...BaselineProfileCaptureStartSurfaceTest.php | 9 +- ...BaselineProfileCompareStartSurfaceTest.php | 9 +- .../InteractsWithFindingsWorkflow.php | 1 - .../Findings/FindingWorkflowGuardTest.php | 5 +- .../Findings/FindingsIntakeQueueTest.php | 4 +- ...eAcknowledgedCompatibilityWorkflowTest.php | 36 +++ .../Feature/Models/FindingResolvedTest.php | 42 ++- .../AuditCoverageGovernanceTest.php | 5 +- .../Monitoring/HeaderContextBarTest.php | 4 +- .../OpsUx/CanonicalViewRunLinksTest.php | 2 +- ...ackupRetentionTerminalNotificationTest.php | 3 +- .../PermissionPostureFindingGeneratorTest.php | 13 +- .../Rbac/RoleMatrix/ManagerAccessTest.php | 2 +- .../Rbac/RoleMatrix/OperatorAccessTest.php | 2 +- .../Rbac/RoleMatrix/OwnerAccessTest.php | 2 +- .../Rbac/RoleMatrix/ReadonlyAccessTest.php | 2 +- .../Support/Badges/FindingBadgeTest.php | 6 +- .../TenantRBAC/RoleDefinitionsSyncNowTest.php | 3 +- ...antReviewCanonicalControlReferenceTest.php | 29 +- .../GlobalContextShellContractTest.php | 2 +- .../tests/Unit/Badges/FindingBadgesTest.php | 2 +- .../Findings/FindingStatusSemanticsTest.php | 22 ++ .../OperationLifecyclePolicyValidatorTest.php | 25 +- .../FindingStatusFilterCatalogTest.php | 22 ++ .../GovernanceInboxSectionBuilderTest.php | 2 + .../ProductTelemetryRecorderTest.php | 23 ++ .../checklists/requirements.md | 48 +++ ...-acknowledged-compat-removal.contract.yaml | 121 ++++++++ .../data-model.md | 103 +++++++ specs/254-remove-acknowledged-compat/plan.md | 266 ++++++++++++++++ .../quickstart.md | 36 +++ .../research.md | 129 ++++++++ specs/254-remove-acknowledged-compat/spec.md | 283 ++++++++++++++++++ specs/254-remove-acknowledged-compat/tasks.md | 238 +++++++++++++++ 67 files changed, 1579 insertions(+), 269 deletions(-) create mode 100644 apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php create mode 100644 apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php create mode 100644 apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php create mode 100644 apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php create mode 100644 specs/254-remove-acknowledged-compat/checklists/requirements.md create mode 100644 specs/254-remove-acknowledged-compat/contracts/findings-acknowledged-compat-removal.contract.yaml create mode 100644 specs/254-remove-acknowledged-compat/data-model.md create mode 100644 specs/254-remove-acknowledged-compat/plan.md create mode 100644 specs/254-remove-acknowledged-compat/quickstart.md create mode 100644 specs/254-remove-acknowledged-compat/research.md create mode 100644 specs/254-remove-acknowledged-compat/spec.md create mode 100644 specs/254-remove-acknowledged-compat/tasks.md diff --git a/apps/platform/app/Console/Commands/PurgeLegacyBaselineGapRuns.php b/apps/platform/app/Console/Commands/PurgeLegacyBaselineGapRuns.php index 95a977fe..e012742d 100644 --- a/apps/platform/app/Console/Commands/PurgeLegacyBaselineGapRuns.php +++ b/apps/platform/app/Console/Commands/PurgeLegacyBaselineGapRuns.php @@ -6,12 +6,14 @@ use App\Models\OperationRun; use App\Models\Tenant; +use App\Support\OperationCatalog; +use App\Support\OperationRunType; use Illuminate\Console\Command; class PurgeLegacyBaselineGapRuns extends Command { protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs - {--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs} + {--type=* : Limit cleanup to baseline.compare and/or baseline.capture runs (legacy aliases also accepted)} {--tenant=* : Limit cleanup to tenant ids or tenant external ids} {--workspace=* : Limit cleanup to workspace ids} {--limit=500 : Maximum candidate runs to inspect} @@ -99,21 +101,35 @@ public function handle(): int */ private function normalizedTypes(): array { - $types = array_values(array_unique(array_filter( + $requestedTypes = array_values(array_unique(array_filter( array_map( static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null, (array) $this->option('type'), ), ))); - if ($types === []) { - return ['baseline_compare', 'baseline_capture']; + $canonicalTypes = array_values(array_unique(array_filter(array_map( + static fn (string $type): ?string => match ($type) { + OperationRunType::BaselineCompare->value, 'baseline_compare' => OperationRunType::BaselineCompare->value, + OperationRunType::BaselineCapture->value, 'baseline_capture' => OperationRunType::BaselineCapture->value, + default => null, + }, + $requestedTypes, + )))); + + if ($canonicalTypes === []) { + $canonicalTypes = [ + OperationRunType::BaselineCompare->value, + OperationRunType::BaselineCapture->value, + ]; } - return array_values(array_filter( - $types, - static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true), - )); + return array_values(array_unique(array_merge( + ...array_map( + static fn (string $type): array => OperationCatalog::rawValuesForCanonical($type), + $canonicalTypes, + ), + ))); } /** diff --git a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php index 8b41da58..520f4c52 100644 --- a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php +++ b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php @@ -16,6 +16,11 @@ use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Filament\TablePaginationProfiles; use App\Support\ReviewPackStatus; +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\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome; @@ -57,6 +62,16 @@ class CustomerReviewWorkspace extends Page implements HasTable protected string $view = 'filament.pages.reviews.customer-review-workspace'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the customer review workspace.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The customer review workspace remains scan-first and does not expose bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA when filters are active.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the latest published review detail instead of an inline canonical detail panel.'); + } + public static function getNavigationGroup(): string { return __('localization.review.reporting'); diff --git a/apps/platform/app/Filament/Resources/FindingResource.php b/apps/platform/app/Filament/Resources/FindingResource.php index d7e1f83e..bf92c24e 100644 --- a/apps/platform/app/Filament/Resources/FindingResource.php +++ b/apps/platform/app/Filament/Resources/FindingResource.php @@ -308,8 +308,6 @@ public static function infolist(Schema $schema): Schema ? OperationRunLinks::tenantlessView((int) $record->current_operation_run_id, static::findingRunNavigationContext($record)) : null) ->openUrlInNewTab(), - TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'), - TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'), TextEntry::make('first_seen_at')->label('First seen')->dateTime()->placeholder('—'), TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'), TextEntry::make('times_seen')->label('Times seen')->placeholder('—'), @@ -1000,7 +998,6 @@ public static function table(Table $table): Table if (! in_array((string) $record->status, [ Finding::STATUS_NEW, Finding::STATUS_REOPENED, - Finding::STATUS_ACKNOWLEDGED, ], true)) { $skippedCount++; @@ -1416,7 +1413,6 @@ public static function triageAction(): Actions\Action ->visible(fn (Finding $record): bool => in_array((string) $record->status, [ Finding::STATUS_NEW, Finding::STATUS_REOPENED, - Finding::STATUS_ACKNOWLEDGED, ], true)) ->action(function (Finding $record, FindingWorkflowService $workflow): void { static::runWorkflowMutation( @@ -1441,7 +1437,6 @@ public static function startProgressAction(): Actions\Action ->color('info') ->visible(fn (Finding $record): bool => in_array((string) $record->status, [ Finding::STATUS_TRIAGED, - Finding::STATUS_ACKNOWLEDGED, ], true)) ->action(function (Finding $record, FindingWorkflowService $workflow): void { static::runWorkflowMutation( diff --git a/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php b/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php index d4796c04..633920ea 100644 --- a/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php +++ b/apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php @@ -171,7 +171,6 @@ protected function getHeaderActions(): array if (! in_array((string) $finding->status, [ Finding::STATUS_NEW, Finding::STATUS_REOPENED, - Finding::STATUS_ACKNOWLEDGED, ], true)) { $skippedCount++; diff --git a/apps/platform/app/Filament/System/Pages/Ops/Controls.php b/apps/platform/app/Filament/System/Pages/Ops/Controls.php index a5f39efa..9fe0d816 100644 --- a/apps/platform/app/Filament/System/Pages/Ops/Controls.php +++ b/apps/platform/app/Filament/System/Pages/Ops/Controls.php @@ -57,11 +57,6 @@ public static function canAccess(): bool && $user->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE); } - public function mount(): void - { - abort_unless(static::canAccess(), 403); - } - public function getHeader(): ?View { return view('filament.system.pages.ops.partials.controls-header', [ diff --git a/apps/platform/app/Models/Finding.php b/apps/platform/app/Models/Finding.php index cb49b575..d37c6a0f 100644 --- a/apps/platform/app/Models/Finding.php +++ b/apps/platform/app/Models/Finding.php @@ -33,8 +33,6 @@ class Finding extends Model public const string STATUS_NEW = 'new'; - public const string STATUS_ACKNOWLEDGED = 'acknowledged'; - public const string STATUS_TRIAGED = 'triaged'; public const string STATUS_IN_PROGRESS = 'in_progress'; @@ -169,10 +167,7 @@ public static function terminalStatuses(): array */ public static function openStatusesForQuery(): array { - return [ - ...self::openStatuses(), - self::STATUS_ACKNOWLEDGED, - ]; + return self::openStatuses(); } /** @@ -295,10 +290,6 @@ public static function isReopenReason(?string $reason): bool public static function canonicalizeStatus(?string $status): ?string { - if ($status === self::STATUS_ACKNOWLEDGED) { - return self::STATUS_TRIAGED; - } - return $status; } @@ -324,23 +315,6 @@ public function isRiskAccepted(): bool return (string) $this->status === self::STATUS_RISK_ACCEPTED; } - public function acknowledge(User $user): self - { - if ($this->status === self::STATUS_ACKNOWLEDGED) { - return $this; - } - - $this->forceFill([ - 'status' => self::STATUS_ACKNOWLEDGED, - 'acknowledged_at' => now(), - 'acknowledged_by_user_id' => $user->getKey(), - ]); - - $this->save(); - - return $this; - } - public function resolve(string $reason): self { $this->forceFill([ diff --git a/apps/platform/app/Policies/FindingPolicy.php b/apps/platform/app/Policies/FindingPolicy.php index c84c2f0a..01144ad0 100644 --- a/apps/platform/app/Policies/FindingPolicy.php +++ b/apps/platform/app/Policies/FindingPolicy.php @@ -49,10 +49,7 @@ public function update(User $user, Finding $finding): Response|bool public function triage(User $user, Finding $finding): Response|bool { - return $this->canMutateWithAnyCapability($user, $finding, [ - Capabilities::TENANT_FINDINGS_TRIAGE, - Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, - ]); + return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_TRIAGE); } public function assign(User $user, Finding $finding): Response|bool diff --git a/apps/platform/app/Services/Auth/RoleCapabilityMap.php b/apps/platform/app/Services/Auth/RoleCapabilityMap.php index 49b51cdd..3f92c665 100644 --- a/apps/platform/app/Services/Auth/RoleCapabilityMap.php +++ b/apps/platform/app/Services/Auth/RoleCapabilityMap.php @@ -28,7 +28,6 @@ class RoleCapabilityMap Capabilities::TENANT_FINDINGS_RESOLVE, Capabilities::TENANT_FINDINGS_CLOSE, Capabilities::TENANT_FINDINGS_RISK_ACCEPT, - Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, Capabilities::FINDING_EXCEPTION_VIEW, Capabilities::FINDING_EXCEPTION_MANAGE, Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, @@ -74,7 +73,6 @@ class RoleCapabilityMap Capabilities::TENANT_FINDINGS_RESOLVE, Capabilities::TENANT_FINDINGS_CLOSE, Capabilities::TENANT_FINDINGS_RISK_ACCEPT, - Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, Capabilities::FINDING_EXCEPTION_VIEW, Capabilities::FINDING_EXCEPTION_MANAGE, Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, @@ -112,7 +110,6 @@ class RoleCapabilityMap Capabilities::TENANT_INVENTORY_SYNC_RUN, Capabilities::TENANT_FINDINGS_VIEW, Capabilities::TENANT_FINDINGS_TRIAGE, - Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, Capabilities::FINDING_EXCEPTION_VIEW, Capabilities::TENANT_MEMBERSHIP_VIEW, diff --git a/apps/platform/app/Services/Findings/FindingWorkflowService.php b/apps/platform/app/Services/Findings/FindingWorkflowService.php index c6ac3e13..2230bc1e 100644 --- a/apps/platform/app/Services/Findings/FindingWorkflowService.php +++ b/apps/platform/app/Services/Findings/FindingWorkflowService.php @@ -46,17 +46,13 @@ public static function meaningfulActivityActionValues(): array public function triage(Finding $finding, Tenant $tenant, User $actor): Finding { - $this->authorize($finding, $tenant, $actor, [ - Capabilities::TENANT_FINDINGS_TRIAGE, - Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, - ]); + $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]); $currentStatus = (string) $finding->status; if (! in_array($currentStatus, [ Finding::STATUS_NEW, Finding::STATUS_REOPENED, - Finding::STATUS_ACKNOWLEDGED, ], true)) { throw new InvalidArgumentException('Finding cannot be triaged from the current status.'); } @@ -82,12 +78,9 @@ public function triage(Finding $finding, Tenant $tenant, User $actor): Finding public function startProgress(Finding $finding, Tenant $tenant, User $actor): Finding { - $this->authorize($finding, $tenant, $actor, [ - Capabilities::TENANT_FINDINGS_TRIAGE, - Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, - ]); + $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]); - if (! in_array((string) $finding->status, [Finding::STATUS_TRIAGED, Finding::STATUS_ACKNOWLEDGED], true)) { + if ((string) $finding->status !== Finding::STATUS_TRIAGED) { throw new InvalidArgumentException('Finding cannot be moved to in-progress from the current status.'); } @@ -369,10 +362,7 @@ private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant public function reopen(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding { - $this->authorize($finding, $tenant, $actor, [ - Capabilities::TENANT_FINDINGS_TRIAGE, - Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, - ]); + $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]); if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) { throw new InvalidArgumentException('Only terminal findings can be reopened.'); diff --git a/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php b/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php index 7abf7599..5ba43e04 100644 --- a/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php +++ b/apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php @@ -128,7 +128,7 @@ private function openRisksSection(?EvidenceSnapshotItem $findingsItem): array { $summary = $this->summary($findingsItem); $entries = collect(Arr::wrap($summary['entries'] ?? [])) - ->filter(static fn (mixed $entry): bool => is_array($entry) && in_array((string) ($entry['status'] ?? ''), ['open', 'in_progress', 'acknowledged'], true)) + ->filter(static fn (mixed $entry): bool => is_array($entry) && in_array((string) ($entry['status'] ?? ''), ['open', 'new', 'triaged', 'in_progress', 'reopened'], true)) ->sortByDesc(static fn (array $entry): int => match ((string) ($entry['severity'] ?? 'low')) { 'critical' => 4, 'high' => 3, diff --git a/apps/platform/app/Support/Auth/Capabilities.php b/apps/platform/app/Support/Auth/Capabilities.php index 81ec07e2..60927136 100644 --- a/apps/platform/app/Support/Auth/Capabilities.php +++ b/apps/platform/app/Support/Auth/Capabilities.php @@ -91,8 +91,6 @@ class Capabilities public const TENANT_FINDINGS_RISK_ACCEPT = 'tenant_findings.risk_accept'; - public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge'; - public const FINDING_EXCEPTION_VIEW = 'finding_exception.view'; public const FINDING_EXCEPTION_MANAGE = 'finding_exception.manage'; diff --git a/apps/platform/app/Support/Baselines/BaselineCompareStats.php b/apps/platform/app/Support/Baselines/BaselineCompareStats.php index 880333e4..ae5c8a9b 100644 --- a/apps/platform/app/Support/Baselines/BaselineCompareStats.php +++ b/apps/platform/app/Support/Baselines/BaselineCompareStats.php @@ -796,7 +796,6 @@ private static function findingAttentionCounts(Tenant $tenant): array $activeNonNewFindingsCount = Finding::query() ->where('tenant_id', $tenantId) ->whereIn('status', [ - Finding::STATUS_ACKNOWLEDGED, Finding::STATUS_TRIAGED, Finding::STATUS_IN_PROGRESS, Finding::STATUS_REOPENED, diff --git a/apps/platform/app/Support/Concerns/DerivesWorkspaceIdFromTenant.php b/apps/platform/app/Support/Concerns/DerivesWorkspaceIdFromTenant.php index 96001021..b19a7e1e 100644 --- a/apps/platform/app/Support/Concerns/DerivesWorkspaceIdFromTenant.php +++ b/apps/platform/app/Support/Concerns/DerivesWorkspaceIdFromTenant.php @@ -99,7 +99,7 @@ private static function resolveTenantWorkspaceId(Model $model, int $tenantId): i $tenant = $model->relationLoaded('tenant') ? $model->getRelation('tenant') : null; if (! $tenant instanceof Tenant || (int) $tenant->getKey() !== $tenantId) { - $tenant = Tenant::query()->find($tenantId); + $tenant = Tenant::query()->withTrashed()->find($tenantId); } if (! $tenant instanceof Tenant) { diff --git a/apps/platform/app/Support/Filament/FilterOptionCatalog.php b/apps/platform/app/Support/Filament/FilterOptionCatalog.php index 81b03d6c..fb41668c 100644 --- a/apps/platform/app/Support/Filament/FilterOptionCatalog.php +++ b/apps/platform/app/Support/Filament/FilterOptionCatalog.php @@ -103,9 +103,9 @@ public static function baselineProfileStatuses(): array /** * @return array */ - public static function findingStatuses(bool $includeLegacyAcknowledged = true): array + public static function findingStatuses(): array { - $options = self::badgeOptions(BadgeDomain::FindingStatus, [ + return self::badgeOptions(BadgeDomain::FindingStatus, [ Finding::STATUS_NEW, Finding::STATUS_TRIAGED, Finding::STATUS_IN_PROGRESS, @@ -114,21 +114,6 @@ public static function findingStatuses(bool $includeLegacyAcknowledged = true): Finding::STATUS_CLOSED, Finding::STATUS_RISK_ACCEPTED, ]); - - if (! $includeLegacyAcknowledged) { - return $options; - } - - return [ - Finding::STATUS_NEW => $options[Finding::STATUS_NEW], - Finding::STATUS_TRIAGED => $options[Finding::STATUS_TRIAGED], - Finding::STATUS_ACKNOWLEDGED => self::legacyFindingAcknowledgedLabel(), - Finding::STATUS_IN_PROGRESS => $options[Finding::STATUS_IN_PROGRESS], - Finding::STATUS_REOPENED => $options[Finding::STATUS_REOPENED], - Finding::STATUS_RESOLVED => $options[Finding::STATUS_RESOLVED], - Finding::STATUS_CLOSED => $options[Finding::STATUS_CLOSED], - Finding::STATUS_RISK_ACCEPTED => $options[Finding::STATUS_RISK_ACCEPTED], - ]; } /** @@ -312,11 +297,6 @@ private static function badgeOptions(BadgeDomain $domain, array $values): array ->all(); } - private static function legacyFindingAcknowledgedLabel(): string - { - return BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_ACKNOWLEDGED)->label.' (legacy acknowledged)'; - } - private static function platformLabel(string $platform): string { return match (Str::of($platform) diff --git a/apps/platform/app/Support/OperationCatalog.php b/apps/platform/app/Support/OperationCatalog.php index dd5b31ba..5778b251 100644 --- a/apps/platform/app/Support/OperationCatalog.php +++ b/apps/platform/app/Support/OperationCatalog.php @@ -289,27 +289,36 @@ private static function operationAliases(): array return [ new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true), new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', false, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'), + new OperationTypeAlias('policy.snapshot', 'policy.snapshot', 'canonical', true), new OperationTypeAlias('policy.capture_snapshot', 'policy.snapshot', 'canonical', true), new OperationTypeAlias('policy.delete', 'policy.delete', 'canonical', true), + new OperationTypeAlias('policy.restore', 'policy.restore', 'canonical', true), new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', false, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'), new OperationTypeAlias('policy.export', 'policy.export', 'canonical', true), new OperationTypeAlias('provider.connection.check', 'provider.connection.check', 'canonical', true), + new OperationTypeAlias('inventory.sync', 'inventory.sync', 'canonical', true), new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', false, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'), new OperationTypeAlias('provider.inventory.sync', 'inventory.sync', 'legacy_alias', false, 'Provider-prefixed historical inventory sync values share the same operator meaning as inventory sync.', 'Avoid emitting provider.inventory.sync on new platform-owned surfaces.'), new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true), new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'), + new OperationTypeAlias('directory.groups.sync', 'directory.groups.sync', 'canonical', true), new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', false, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'), new OperationTypeAlias('backup_set.update', 'backup_set.update', 'canonical', true), + new OperationTypeAlias('backup_set.archive', 'backup_set.archive', 'canonical', true), new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true), new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true), new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', false, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'), + new OperationTypeAlias('backup.schedule.execute', 'backup.schedule.execute', 'canonical', true), new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', false, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'), + new OperationTypeAlias('backup.schedule.retention', 'backup.schedule.retention', 'canonical', true), new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', false, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'), + new OperationTypeAlias('backup.schedule.purge', 'backup.schedule.purge', 'canonical', true), new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'), new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true), new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true), new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true), new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true), + new OperationTypeAlias('directory.role_definitions.sync', 'directory.role_definitions.sync', 'canonical', true), new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', false, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'), new OperationTypeAlias('restore_run.delete', 'restore_run.delete', 'canonical', true), new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true), @@ -324,6 +333,7 @@ private static function operationAliases(): array new OperationTypeAlias('baseline.compare', 'baseline.compare', 'canonical', true), new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', false, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'), new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', false, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'), + new OperationTypeAlias('permission.posture.check', 'permission.posture.check', 'canonical', true), new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', false, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'), new OperationTypeAlias('entra.admin_roles.scan', 'entra.admin_roles.scan', 'canonical', true), new OperationTypeAlias('tenant.review_pack.generate', 'tenant.review_pack.generate', 'canonical', true), diff --git a/apps/platform/database/factories/FindingFactory.php b/apps/platform/database/factories/FindingFactory.php index 1c794878..cb753f6d 100644 --- a/apps/platform/database/factories/FindingFactory.php +++ b/apps/platform/database/factories/FindingFactory.php @@ -73,18 +73,6 @@ public function permissionPosture(): static ]); } - /** - * State for legacy acknowledged findings. - */ - public function acknowledged(): static - { - return $this->state(fn (array $attributes): array => [ - 'status' => Finding::STATUS_ACKNOWLEDGED, - 'acknowledged_at' => now(), - 'acknowledged_by_user_id' => null, - ]); - } - /** * State for triaged findings. */ diff --git a/apps/platform/resources/views/filament/components/product-knowledge/contextual-help.blade.php b/apps/platform/resources/views/filament/components/product-knowledge/contextual-help.blade.php index 98c0b406..bddf53c1 100644 --- a/apps/platform/resources/views/filament/components/product-knowledge/contextual-help.blade.php +++ b/apps/platform/resources/views/filament/components/product-knowledge/contextual-help.blade.php @@ -1,7 +1,10 @@ @php + use App\Support\Verification\VerificationLinkBehavior; + $help = is_array($help ?? null) ? $help : []; $links = is_array($help['docs_links'] ?? null) ? $help['docs_links'] : []; $steps = is_array($help['troubleshooting_steps'] ?? null) ? $help['troubleshooting_steps'] : []; + $linkBehavior = app(VerificationLinkBehavior::class); $headline = is_string($help['headline'] ?? null) && trim((string) ($help['headline'] ?? '')) !== '' ? (string) ($help['headline']) : 'Contextual help'; @@ -57,9 +60,16 @@
      @foreach ($links as $link) @php + $linkLabel = is_string($link['label'] ?? null) && trim((string) ($link['label'] ?? '')) !== '' + ? (string) $link['label'] + : 'Open'; $linkUrl = is_string($link['url'] ?? null) && trim((string) ($link['url'] ?? '')) !== '' ? (string) $link['url'] : null; + $behavior = $linkUrl !== null + ? $linkBehavior->describe($linkLabel, $linkUrl) + : null; + $testId = 'contextual-help-link-'.\Illuminate\Support\Str::slug($linkLabel); @endphp @if ($linkUrl) @@ -68,8 +78,11 @@ :href="$linkUrl" size="sm" color="primary" + :target="(bool) ($behavior['opens_in_new_tab'] ?? false) ? '_blank' : null" + :rel="(bool) ($behavior['opens_in_new_tab'] ?? false) ? 'noopener noreferrer' : null" + :data-testid="$testId" > - {{ (string) ($link['label'] ?? 'Open') }} + {{ $linkLabel }} @endif @endforeach diff --git a/apps/platform/tests/Browser/OnboardingDraftRefreshTest.php b/apps/platform/tests/Browser/OnboardingDraftRefreshTest.php index b1072590..6bfea3eb 100644 --- a/apps/platform/tests/Browser/OnboardingDraftRefreshTest.php +++ b/apps/platform/tests/Browser/OnboardingDraftRefreshTest.php @@ -61,18 +61,6 @@ ]); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); - $visibleSelectValue = <<<'JS' -(() => { - const select = [...document.querySelectorAll('select')].find((element) => { - const style = window.getComputedStyle(element); - - return style.display !== 'none' && style.visibility !== 'hidden'; - }); - - return select?.value ?? null; -})() -JS; - $page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])); $page @@ -87,8 +75,8 @@ ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertSee('Verify access') ->assertSee('Status: Not started') - ->click('Provider connection') - ->assertScript($visibleSelectValue, (string) $connection->getKey()) + ->click('Select an existing connection or create a new one.') + ->assertSee('Edit selected connection') ->click('Create new connection') ->check('internal:label="Dedicated override"s') ->fill('[type="password"]', 'browser-only-secret') @@ -97,8 +85,8 @@ ->waitForText('Status: Not started') ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertSee('Verify access') - ->click('Provider connection') - ->assertScript($visibleSelectValue, (string) $connection->getKey()) + ->click('Select an existing connection or create a new one.') + ->assertSee('Edit selected connection') ->click('Create new connection') ->check('internal:label="Dedicated override"s') ->assertValue('[type="password"]', ''); diff --git a/apps/platform/tests/Browser/OnboardingDraftVerificationResumeTest.php b/apps/platform/tests/Browser/OnboardingDraftVerificationResumeTest.php index 56bd5cda..b361e5a9 100644 --- a/apps/platform/tests/Browser/OnboardingDraftVerificationResumeTest.php +++ b/apps/platform/tests/Browser/OnboardingDraftVerificationResumeTest.php @@ -86,18 +86,6 @@ ]); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); - $visibleSelectValue = <<<'JS' -(() => { - const select = [...document.querySelectorAll('select')].find((element) => { - const style = window.getComputedStyle(element); - - return style.display !== 'none' && style.visibility !== 'hidden'; - }); - - return select?.value ?? null; -})() -JS; - $page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])); $page @@ -113,8 +101,8 @@ ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertSee('Status: Needs attention') ->assertSee('Start verification') - ->click('Provider connection') - ->assertScript($visibleSelectValue, (string) $selectedConnection->getKey()); + ->click('Select an existing connection or create a new one.') + ->assertSee('Edit selected connection'); }); it('preserves bootstrap revisit state and blocked activation guards after refresh', function (): void { @@ -328,32 +316,14 @@ ->assertNoJavaScriptErrors() ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->wait(1) - ->assertScript("document.querySelector('[data-testid=\"verification-assist-trigger\"]') !== null", true) - ->click('[data-testid="verification-assist-trigger"]') - ->assertScript("document.querySelector('[data-testid=\"verification-assist-root\"]') !== null", true) - ->assertAttribute('[data-testid="verification-assist-full-page"]', 'target', '_blank'); - - $page->script(<<<'JS' -Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { - writeText: async () => Promise.resolve(), - }, -}); - -document.querySelector('[data-testid="verification-assist-copy-application"]')?.click(); -JS); - - $page - ->waitForText('Copied') - ->assertAttribute('[data-testid="verification-assist-full-page"]', 'rel', 'noopener noreferrer') - ->click('[data-testid="verification-assist-full-page"]') + ->assertScript("document.querySelector('[data-testid=\"contextual-help-link-open-required-permissions\"]') !== null", true) + ->assertAttribute('[data-testid="contextual-help-link-open-required-permissions"]', 'target', '_blank') + ->assertAttribute('[data-testid="contextual-help-link-open-required-permissions"]', 'rel', 'noopener noreferrer') + ->click('[data-testid="contextual-help-link-open-required-permissions"]') ->wait(1) ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) - ->assertScript("document.querySelector('[data-testid=\"verification-assist-root\"]') !== null", true) - ->click('Close') - ->click('Provider connection') - ->assertSee('Select an existing connection or create a new one.'); + ->click('Select an existing connection or create a new one.') + ->assertSee('Edit selected connection'); }); it('opens the permissions assist from report remediation steps without leaving onboarding', function (): void { diff --git a/apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php b/apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php new file mode 100644 index 00000000..7e41118a --- /dev/null +++ b/apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php @@ -0,0 +1,23 @@ +toBeFalse(); + expect(RoleCapabilityMap::rolesWithCapability('tenant_findings.acknowledge'))->toBe([]); +}); + +it('keeps the canonical findings triage capability available to operators', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + expect(RoleCapabilityMap::hasCapability(TenantRole::Operator, Capabilities::TENANT_FINDINGS_TRIAGE))->toBeTrue(); + expect(Gate::forUser($user)->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue(); +}); diff --git a/apps/platform/tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php b/apps/platform/tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php index d52d76bc..4f6024ef 100644 --- a/apps/platform/tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php +++ b/apps/platform/tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php @@ -9,6 +9,7 @@ use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Support\Baselines\BaselineCaptureMode; +use App\Support\OperationRunType; it('writes audit events for baseline capture start and completion with scope + gap summary', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -35,7 +36,7 @@ $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), diff --git a/apps/platform/tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php b/apps/platform/tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php index 69174e52..e1d2a474 100644 --- a/apps/platform/tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php +++ b/apps/platform/tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php @@ -58,7 +58,7 @@ $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), diff --git a/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php b/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php index 42a9c1c5..3dce3a01 100644 --- a/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php +++ b/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php @@ -12,6 +12,7 @@ use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Support\Baselines\BaselineSubjectKey; +use App\Support\OperationRunType; it('Baseline capture stores content fidelity hash when PolicyVersion evidence exists', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -73,7 +74,7 @@ $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), diff --git a/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php b/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php index d57d952f..1e504b70 100644 --- a/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php +++ b/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php @@ -18,6 +18,7 @@ use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineSubjectKey; use App\Support\Baselines\PolicyVersionCapturePurpose; +use App\Support\OperationRunType; it('Baseline capture (full content) captures evidence on demand when missing', function () { config()->set('tenantpilot.baselines.full_content_capture.enabled', true); @@ -119,7 +120,7 @@ public function capture( $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), diff --git a/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php b/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php index 53844846..b7006446 100644 --- a/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php +++ b/apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php @@ -64,7 +64,7 @@ $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), diff --git a/apps/platform/tests/Feature/Baselines/BaselineCaptureRbacRoleDefinitionsTest.php b/apps/platform/tests/Feature/Baselines/BaselineCaptureRbacRoleDefinitionsTest.php index 3e71d06c..4551b9e7 100644 --- a/apps/platform/tests/Feature/Baselines/BaselineCaptureRbacRoleDefinitionsTest.php +++ b/apps/platform/tests/Feature/Baselines/BaselineCaptureRbacRoleDefinitionsTest.php @@ -14,6 +14,7 @@ use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; use App\Support\Baselines\BaselineSubjectKey; +use App\Support\OperationRunType; it('captures intune role definitions with identity metadata and excludes role assignments from the baseline snapshot', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -104,7 +105,7 @@ $operationRuns = app(OperationRunService::class); $run = $operationRuns->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), diff --git a/apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php b/apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php index be4cc458..6c9f9cc5 100644 --- a/apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php +++ b/apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php @@ -17,6 +17,7 @@ use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineSnapshotLifecycleState; use App\Support\Baselines\BaselineSubjectKey; +use App\Support\OperationRunType; use Illuminate\Support\Facades\Queue; function createBaselineCaptureInventoryBasis( @@ -65,7 +66,7 @@ function runBaselineCaptureJob( /** @var OperationRun $run */ $run = $result['run']; - expect($run->type)->toBe('baseline_capture'); + expect($run->type)->toBe(OperationRunType::BaselineCapture->value); expect($run->status)->toBe('queued'); expect($run->tenant_id)->toBe((int) $tenant->getKey()); @@ -104,7 +105,7 @@ function runBaselineCaptureJob( expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_MISSING); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); it('rejects capture when the latest inventory sync was blocked', function () { @@ -135,7 +136,7 @@ function runBaselineCaptureJob( expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); it('rejects capture when the latest inventory sync failed without falling back to an older success', function () { @@ -166,7 +167,7 @@ function runBaselineCaptureJob( expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); it('rejects capture when the latest inventory coverage is unusable for the baseline scope', function () { @@ -189,7 +190,7 @@ function runBaselineCaptureJob( expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); it('rejects capture for a draft profile with reason code', function () { @@ -209,7 +210,7 @@ function runBaselineCaptureJob( expect($result['reason_code'])->toBe('baseline.capture.profile_not_active'); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); it('rejects capture for an archived profile with reason code', function () { @@ -228,7 +229,7 @@ function runBaselineCaptureJob( expect($result['reason_code'])->toBe('baseline.capture.profile_not_active'); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); it('rejects capture for a tenant from a different workspace', function () { @@ -274,7 +275,7 @@ function runBaselineCaptureJob( expect($result2['ok'])->toBeTrue(); expect($result1['run']->getKey())->toBe($result2['run']->getKey()); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(1); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(1); }); // --- Snapshot dedupe + capture job execution --- @@ -321,7 +322,7 @@ function runBaselineCaptureJob( $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), @@ -476,7 +477,7 @@ function runBaselineCaptureJob( $run1 = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), @@ -499,7 +500,7 @@ function runBaselineCaptureJob( 'tenant_id' => (int) $tenant->getKey(), 'user_id' => (int) $user->getKey(), 'initiator_name' => $user->name, - 'type' => 'baseline_capture', + 'type' => OperationRunType::BaselineCapture->value, 'status' => 'queued', 'outcome' => 'pending', 'run_identity_hash' => hash('sha256', 'second-run-'.now()->timestamp), @@ -586,7 +587,7 @@ function runBaselineCaptureJob( $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), @@ -662,7 +663,7 @@ function runBaselineCaptureJob( $opService = app(OperationRunService::class); $run = $opService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), diff --git a/apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php b/apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php index a91dc7eb..561aaaf2 100644 --- a/apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php +++ b/apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php @@ -15,6 +15,7 @@ use App\Support\Governance\GovernanceSubjectTaxonomyRegistry; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\OperationRunType; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; @@ -69,14 +70,14 @@ $activeRuns = OperationRun::query() ->where('workspace_id', (int) $fixture['workspace']->getKey()) - ->where('type', 'baseline_compare') + ->where('type', OperationRunType::BaselineCompare->value) ->get(); expect($activeRuns)->toHaveCount(2) ->and($activeRuns->every(static fn (OperationRun $run): bool => $run->tenant_id !== null))->toBeTrue() ->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->status === OperationRunStatus::Queued->value))->toBeTrue() ->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->outcome === OperationRunOutcome::Pending->value))->toBeTrue() - ->and(OperationRun::query()->whereNull('tenant_id')->where('type', 'baseline_compare')->count())->toBe(0); + ->and(OperationRun::query()->whereNull('tenant_id')->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); }); it('runs compare assigned tenants from the matrix page and keeps feedback on tenant-owned runs', function (): void { @@ -97,7 +98,7 @@ expect(OperationRun::query() ->where('workspace_id', (int) $fixture['workspace']->getKey()) - ->where('type', 'baseline_compare') + ->where('type', OperationRunType::BaselineCompare->value) ->whereNull('tenant_id') ->count())->toBe(0); }); diff --git a/apps/platform/tests/Feature/DirectoryGroups/StartSyncTest.php b/apps/platform/tests/Feature/DirectoryGroups/StartSyncTest.php index e9a8c70c..bedea333 100644 --- a/apps/platform/tests/Feature/DirectoryGroups/StartSyncTest.php +++ b/apps/platform/tests/Feature/DirectoryGroups/StartSyncTest.php @@ -3,6 +3,7 @@ use App\Jobs\EntraGroupSyncJob; use App\Services\Directory\EntraGroupSyncService; use App\Services\Providers\ProviderOperationStartResult; +use App\Support\OperationRunType; use Illuminate\Support\Facades\Queue; it('starts a manual group sync by creating a run and dispatching a job', function () { @@ -21,7 +22,7 @@ expect($run) ->and($run->tenant_id)->toBe($tenant->getKey()) ->and($run->user_id)->toBe($user->getKey()) - ->and($run->type)->toBe('entra_group_sync') + ->and($run->type)->toBe(OperationRunType::DirectoryGroupsSync->value) ->and($run->status)->toBe('queued') ->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all') ->and($run->context['provider_connection_id'] ?? null)->toBeInt(); diff --git a/apps/platform/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php b/apps/platform/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php index eaa512d2..717ca5b3 100644 --- a/apps/platform/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php +++ b/apps/platform/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php @@ -7,6 +7,7 @@ use App\Services\Graph\GraphResponse; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\OperationRunType; it('sync job upserts groups and updates run counters', function () { @@ -54,7 +55,7 @@ $opService = app(OperationRunService::class); $opRun = $opService->ensureRun( tenant: $tenant, - type: 'entra_group_sync', + type: OperationRunType::DirectoryGroupsSync->value, inputs: ['selection_key' => 'groups-v1:all'], initiator: $user, ); diff --git a/apps/platform/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php b/apps/platform/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php index ccad36d5..1f7929b6 100644 --- a/apps/platform/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php +++ b/apps/platform/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php @@ -7,6 +7,7 @@ use App\Services\Graph\GraphResponse; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\OperationRunType; use Illuminate\Support\Facades\Config; it('purges cached groups older than the retention window', function () { @@ -34,7 +35,7 @@ $opService = app(OperationRunService::class); $opRun = $opService->ensureRun( tenant: $tenant, - type: 'entra_group_sync', + type: OperationRunType::DirectoryGroupsSync->value, inputs: ['selection_key' => 'groups-v1:all'], initiator: $user, ); diff --git a/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php b/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php index 420acb84..52be5f0e 100644 --- a/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php +++ b/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php @@ -444,7 +444,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator ->and($finding->subject_external_id)->toBe('user-1:def-ga'); }); -it('auto-resolve applies to acknowledged findings too', function (): void { +it('auto-resolve applies to triaged findings too', function (): void { [$user, $tenant] = createMinimalUserWithTenant(); $generator = makeGenerator(); @@ -456,20 +456,19 @@ function makeGenerator(): EntraAdminRolesFindingGenerator ); $generator->generate($tenant, $payload); - // Acknowledge the finding + // Triage the finding $finding = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('subject_external_id', 'user-1:def-ga') ->first(); $finding->forceFill([ - 'status' => Finding::STATUS_ACKNOWLEDGED, - 'acknowledged_at' => now(), - 'acknowledged_by_user_id' => $user->getKey(), + 'status' => Finding::STATUS_TRIAGED, + 'triaged_at' => now(), ])->save(); - expect($finding->fresh()->status)->toBe(Finding::STATUS_ACKNOWLEDGED); + expect($finding->fresh()->status)->toBe(Finding::STATUS_TRIAGED); - // Scan 2: remove → should auto-resolve even though acknowledged + // Scan 2: remove -> should auto-resolve even though triaged $payload2 = buildPayload([gaRoleDef()], []); $result = $generator->generate($tenant, $payload2); diff --git a/apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php b/apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php index b8b23512..9894f08f 100644 --- a/apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php +++ b/apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php @@ -115,7 +115,7 @@ $run = OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) - ->where('type', 'baseline_compare') + ->where('type', OperationRunType::BaselineCompare->value) ->latest('id') ->first(); @@ -192,7 +192,7 @@ ->assertStatus(200); Queue::assertNotPushed(CompareBaselineToTenantJob::class); - expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); }); it('shows mixed-strategy compare rejection truth on the tenant landing surface', function (): void { @@ -250,7 +250,7 @@ ->assertStatus(200); Queue::assertNotPushed(CompareBaselineToTenantJob::class); - expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); }); it('can refresh stats without calling mount directly', function (): void { diff --git a/apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php b/apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php index cb8eb597..d5b01831 100644 --- a/apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php +++ b/apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php @@ -9,6 +9,7 @@ use App\Models\OperationRun; use App\Models\Tenant; use App\Support\Baselines\BaselineCaptureMode; +use App\Support\OperationRunType; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; use Filament\Actions\ActionGroup; @@ -121,7 +122,7 @@ function seedCaptureProfileForTenant( $run = OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) - ->where('type', 'baseline_capture') + ->where('type', OperationRunType::BaselineCapture->value) ->latest('id') ->first(); @@ -151,7 +152,7 @@ function seedCaptureProfileForTenant( ->assertStatus(200); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); it('does not start full-content capture when rollout is disabled', function (): void { @@ -174,7 +175,7 @@ function seedCaptureProfileForTenant( ->assertStatus(200); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); it('shows readiness copy without exposing raw canonical scope json on the capture start surface', function (): void { @@ -228,5 +229,5 @@ function seedCaptureProfileForTenant( ->assertStatus(200); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); - expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); }); diff --git a/apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php b/apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php index 51430cfa..3dfbda3a 100644 --- a/apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php +++ b/apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php @@ -14,6 +14,7 @@ use App\Support\Baselines\Compare\CompareStrategyRegistry; use App\Support\Baselines\Compare\IntuneCompareStrategy; use App\Support\Governance\GovernanceSubjectTaxonomyRegistry; +use App\Support\OperationRunType; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; use Filament\Actions\ActionGroup; @@ -92,7 +93,7 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM $run = OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) - ->where('type', 'baseline_compare') + ->where('type', OperationRunType::BaselineCompare->value) ->latest('id') ->first(); @@ -120,7 +121,7 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM ->assertStatus(200); Queue::assertNotPushed(CompareBaselineToTenantJob::class); - expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); }); it('shows mixed-strategy compare rejection truth on the workspace start surface', function (): void { @@ -167,7 +168,7 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM ->assertStatus(200); Queue::assertNotPushed(CompareBaselineToTenantJob::class); - expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); }); it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void { @@ -275,5 +276,5 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM ->assertStatus(200); Queue::assertNotPushed(CompareBaselineToTenantJob::class); - expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0); + expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); }); diff --git a/apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php b/apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php index d0489100..967f157a 100644 --- a/apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php +++ b/apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php @@ -34,7 +34,6 @@ protected function makeFindingForWorkflow(Tenant $tenant, string $status = Findi $factory = Finding::factory()->for($tenant); $factory = match ($status) { - Finding::STATUS_ACKNOWLEDGED => $factory->acknowledged(), Finding::STATUS_TRIAGED => $factory->triaged(), Finding::STATUS_IN_PROGRESS => $factory->inProgress(), Finding::STATUS_REOPENED => $factory->reopened(), diff --git a/apps/platform/tests/Feature/Findings/FindingWorkflowGuardTest.php b/apps/platform/tests/Feature/Findings/FindingWorkflowGuardTest.php index 3575724f..0a70c68a 100644 --- a/apps/platform/tests/Feature/Findings/FindingWorkflowGuardTest.php +++ b/apps/platform/tests/Feature/Findings/FindingWorkflowGuardTest.php @@ -22,9 +22,8 @@ ->toContain(AuditActionId::FindingReopened->value); }); -it('keeps only legacy compatibility lifecycle helpers on the model', function (): void { - expect(method_exists(Finding::class, 'acknowledge'))->toBeTrue() - ->and(method_exists(Finding::class, 'resolve'))->toBeTrue() +it('keeps only the surviving model lifecycle helpers', function (): void { + expect(method_exists(Finding::class, 'resolve'))->toBeTrue() ->and(method_exists(Finding::class, 'reopen'))->toBeTrue() ->and(method_exists(Finding::class, 'triage'))->toBeFalse() ->and(method_exists(Finding::class, 'startProgress'))->toBeFalse() diff --git a/apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php b/apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php index 6ed71893..1dcac3ff 100644 --- a/apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php +++ b/apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php @@ -101,8 +101,10 @@ function makeIntakeFinding(Tenant $tenant, array $attributes = []): Finding 'assignee_user_id' => (int) $otherAssignee->getKey(), ]); - $acknowledged = Finding::factory()->for($tenantA)->acknowledged()->create([ + $acknowledged = Finding::factory()->for($tenantA)->create([ 'workspace_id' => (int) $tenantA->workspace_id, + 'status' => 'acknowledged', + 'acknowledged_at' => now(), 'assignee_user_id' => null, 'subject_external_id' => 'acknowledged', ]); diff --git a/apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php b/apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php new file mode 100644 index 00000000..880b4aac --- /dev/null +++ b/apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php @@ -0,0 +1,36 @@ +for($tenant)->create([ + 'status' => 'acknowledged', + 'acknowledged_at' => now(), + 'acknowledged_by_user_id' => $user->getKey(), + ]); + + expect(fn () => app(FindingWorkflowService::class)->triage($finding, $tenant, $user)) + ->toThrow(\InvalidArgumentException::class, 'Finding cannot be triaged from the current status.'); +}); + +it('rejects start progress from the removed acknowledged status', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $finding = Finding::factory()->for($tenant)->create([ + 'status' => 'acknowledged', + 'triaged_at' => now()->subMinute(), + 'acknowledged_at' => now(), + 'acknowledged_by_user_id' => $user->getKey(), + ]); + + expect(fn () => app(FindingWorkflowService::class)->startProgress($finding, $tenant, $user)) + ->toThrow(\InvalidArgumentException::class, 'Finding cannot be moved to in-progress from the current status.'); +}); diff --git a/apps/platform/tests/Feature/Models/FindingResolvedTest.php b/apps/platform/tests/Feature/Models/FindingResolvedTest.php index 8e7da394..b937e49c 100644 --- a/apps/platform/tests/Feature/Models/FindingResolvedTest.php +++ b/apps/platform/tests/Feature/Models/FindingResolvedTest.php @@ -59,15 +59,18 @@ ]); }); -it('supports legacy model helper compatibility for acknowledge', function (): void { +it('keeps stale acknowledged metadata as passive data only', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); - $finding = Finding::factory()->for($tenant)->permissionPosture()->create(); + $finding = Finding::factory()->for($tenant)->permissionPosture()->create([ + 'status' => 'acknowledged', + 'acknowledged_at' => now(), + 'acknowledged_by_user_id' => $user->getKey(), + ]); - $finding->acknowledge($user); - - expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED) + expect($finding->status)->toBe('acknowledged') ->and($finding->acknowledged_at)->not->toBeNull() - ->and($finding->acknowledged_by_user_id)->toBe($user->getKey()); + ->and($finding->acknowledged_by_user_id)->toBe($user->getKey()) + ->and($finding->hasOpenStatus())->toBeFalse(); }); it('exposes v2 open and terminal status helpers', function (): void { @@ -84,31 +87,26 @@ Finding::STATUS_RISK_ACCEPTED, ]); - expect(Finding::openStatusesForQuery())->toContain(Finding::STATUS_ACKNOWLEDGED); + expect(Finding::openStatusesForQuery())->toBe(Finding::openStatuses()); }); -it('maps legacy acknowledged status to triaged in v2 helpers', function (): void { - expect(Finding::canonicalizeStatus(Finding::STATUS_ACKNOWLEDGED)) - ->toBe(Finding::STATUS_TRIAGED); +it('does not treat acknowledged as canonical in v2 helpers', function (): void { + expect(Finding::canonicalizeStatus('acknowledged'))->toBe('acknowledged'); - expect(Finding::isOpenStatus(Finding::STATUS_ACKNOWLEDGED))->toBeTrue(); - expect(Finding::isTerminalStatus(Finding::STATUS_ACKNOWLEDGED))->toBeFalse(); + expect(Finding::isOpenStatus('acknowledged'))->toBeFalse(); + expect(Finding::isTerminalStatus('acknowledged'))->toBeFalse(); }); -it('preserves acknowledged metadata when resolving an acknowledged finding', function (): void { +it('rejects resolving a stale acknowledged finding', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); - $finding = Finding::factory()->for($tenant)->permissionPosture()->acknowledged()->create([ + $finding = Finding::factory()->for($tenant)->permissionPosture()->create([ + 'status' => 'acknowledged', + 'acknowledged_at' => now(), 'acknowledged_by_user_id' => $user->getKey(), ]); - expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED); - - $finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED); - - expect($finding->status)->toBe(Finding::STATUS_RESOLVED) - ->and($finding->acknowledged_at)->not->toBeNull() - ->and($finding->acknowledged_by_user_id)->toBe($user->getKey()) - ->and($finding->resolved_at)->not->toBeNull(); + expect(fn () => app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED)) + ->toThrow(\InvalidArgumentException::class, 'Only open findings can be resolved.'); }); it('has STATUS_RESOLVED constant', function (): void { diff --git a/apps/platform/tests/Feature/Monitoring/AuditCoverageGovernanceTest.php b/apps/platform/tests/Feature/Monitoring/AuditCoverageGovernanceTest.php index 8e54c8e3..2593dc49 100644 --- a/apps/platform/tests/Feature/Monitoring/AuditCoverageGovernanceTest.php +++ b/apps/platform/tests/Feature/Monitoring/AuditCoverageGovernanceTest.php @@ -14,6 +14,7 @@ use App\Support\Audit\AuditOutcome; use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineReasonCodes; +use App\Support\OperationRunType; it('derives summary-first audit semantics for baseline capture workflow events', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -36,7 +37,7 @@ $operationRunService = app(OperationRunService::class); $run = $operationRunService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), @@ -97,7 +98,7 @@ $operationRunService = app(OperationRunService::class); $run = $operationRunService->ensureRunWithIdentity( tenant: $tenant, - type: 'baseline_capture', + type: OperationRunType::BaselineCapture->value, identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], context: [ 'baseline_profile_id' => (int) $profile->getKey(), diff --git a/apps/platform/tests/Feature/Monitoring/HeaderContextBarTest.php b/apps/platform/tests/Feature/Monitoring/HeaderContextBarTest.php index 9e9bd69d..802f74cb 100644 --- a/apps/platform/tests/Feature/Monitoring/HeaderContextBarTest.php +++ b/apps/platform/tests/Feature/Monitoring/HeaderContextBarTest.php @@ -26,7 +26,7 @@ ->get('/admin/operations') ->assertOk() ->assertSee($workspaceName ?? 'Select workspace') - ->assertSee('Search tenants…') + ->assertSee(__('localization.shell.search_tenants')) ->assertSee('Switch workspace') ->assertSee('admin/select-tenant') ->assertSee('Clear tenant scope') @@ -66,7 +66,7 @@ ->get('/admin/workspaces') ->assertOk() ->assertSee('Choose a workspace first.') - ->assertDontSee('Search tenants…'); + ->assertDontSee(__('localization.shell.search_tenants')); }); it('keeps tenant-scoped pages selector read-only while exposing the clear tenant scope action', function (): void { diff --git a/apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php b/apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php index 48a0ec19..430de3dd 100644 --- a/apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php +++ b/apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php @@ -93,7 +93,7 @@ 'tenant_id' => (int) $tenant->getKey(), 'tableFilters' => [ 'type' => [ - 'value' => 'inventory_sync', + 'value' => 'inventory.sync', ], ], ])); diff --git a/apps/platform/tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php b/apps/platform/tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php index 40531580..fd950a36 100644 --- a/apps/platform/tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php +++ b/apps/platform/tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php @@ -6,6 +6,7 @@ use App\Models\BackupSchedule; use App\Models\BackupSet; use App\Models\OperationRun; +use App\Support\OperationRunType; it('completes backup retention runs without persisting terminal notifications for system runs', function (): void { [$user, $tenant] = createUserWithTenant(role: 'manager'); @@ -62,7 +63,7 @@ $retentionRun = OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) - ->where('type', 'backup_schedule_retention') + ->where('type', OperationRunType::BackupScheduleRetention->value) ->latest('id') ->first(); diff --git a/apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php b/apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php index f40f6285..1d071e97 100644 --- a/apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php +++ b/apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php @@ -104,19 +104,17 @@ function errorPermission(string $key, array $features = []): array ->and($finding->resolved_reason)->toBe('permission_granted'); }); -// (3) Auto-resolves acknowledged finding preserving metadata -it('auto-resolves acknowledged finding preserving acknowledged metadata', function (): void { +// (3) Auto-resolves triaged finding preserving triaged metadata +it('auto-resolves triaged finding preserving triaged metadata', function (): void { [$user, $tenant] = createUserWithTenant(); $generator = app(PermissionPostureFindingGenerator::class); $generator->generate($tenant, buildComparison([missingPermission('Perm.A')])); $finding = Finding::query()->where('tenant_id', $tenant->getKey())->first(); - $ackUser = User::factory()->create(); $finding->forceFill([ - 'status' => Finding::STATUS_ACKNOWLEDGED, - 'acknowledged_at' => now(), - 'acknowledged_by_user_id' => $ackUser->getKey(), + 'status' => Finding::STATUS_TRIAGED, + 'triaged_at' => now(), ])->save(); $result = $generator->generate($tenant, buildComparison([grantedPermission('Perm.A')], 'granted')); @@ -124,8 +122,7 @@ function errorPermission(string $key, array $features = []): array $finding->refresh(); expect($result->findingsResolved)->toBe(1) ->and($finding->status)->toBe(Finding::STATUS_RESOLVED) - ->and($finding->acknowledged_at)->not->toBeNull() - ->and($finding->acknowledged_by_user_id)->toBe($ackUser->getKey()); + ->and($finding->triaged_at)->not->toBeNull(); }); // (4) No duplicates on idempotent run diff --git a/apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php b/apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php index e89d3faf..fe64889f 100644 --- a/apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php +++ b/apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php @@ -11,7 +11,7 @@ expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue(); - expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant))->toBeTrue(); diff --git a/apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php b/apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php index 6248a0f1..b53abea1 100644 --- a/apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php +++ b/apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php @@ -11,7 +11,7 @@ expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue(); - expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::PROVIDER_VIEW, $tenant))->toBeTrue(); diff --git a/apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php b/apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php index c980ec87..79999812 100644 --- a/apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php +++ b/apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php @@ -11,7 +11,7 @@ expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue(); - expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeTrue(); diff --git a/apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php b/apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php index dbf52092..d1f45122 100644 --- a/apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php +++ b/apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php @@ -16,7 +16,7 @@ expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeFalse(); - expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeFalse(); + expect($gate->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeFalse(); diff --git a/apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php b/apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php index aac27f7b..379c29aa 100644 --- a/apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php +++ b/apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php @@ -24,10 +24,10 @@ ->and($spec->color)->toBe('warning'); }); -it('still renders acknowledged status badge', function (): void { - $spec = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_ACKNOWLEDGED); +it('renders unknown for removed acknowledged status badges', function (): void { + $spec = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'acknowledged'); - expect($spec->label)->toBe('Triaged') + expect($spec->label)->toBe('Unknown') ->and($spec->color)->toBe('gray'); }); diff --git a/apps/platform/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php b/apps/platform/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php index 3c29b46d..7101b43e 100644 --- a/apps/platform/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php +++ b/apps/platform/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php @@ -6,6 +6,7 @@ use App\Services\Providers\ProviderOperationStartResult; use App\Services\Directory\RoleDefinitionsSyncService; use App\Support\OperationRunLinks; +use App\Support\OperationRunType; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; @@ -36,7 +37,7 @@ $run = $result->run; - expect($run->type)->toBe('directory_role_definitions.sync'); + expect($run->type)->toBe(OperationRunType::DirectoryRoleDefinitionsSync->value); expect($run->context['provider_connection_id'] ?? null)->toBeInt(); $url = OperationRunLinks::tenantlessView($run); diff --git a/apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php b/apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php index a2eab817..b9bb3adf 100644 --- a/apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php +++ b/apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use App\Models\Finding; + it('passes shared canonical control references through tenant review composition', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $snapshot = seedTenantReviewEvidence($tenant, findingCount: 0, driftCount: 1); @@ -14,5 +16,30 @@ ->and($review->canonicalControlReferences()[0]['control_key'])->toBe('endpoint_hardening_compliance') ->and($executiveSummary->summary_payload['canonical_control_count'])->toBe(1) ->and($executiveSummary->summary_payload['canonical_controls'][0]['control_key'])->toBe('endpoint_hardening_compliance') - ->and($openRisks->summary_payload['canonical_controls'])->toBe([]); + ->and($openRisks->summary_payload['canonical_controls'][0]['control_key'] ?? null)->toBe('endpoint_hardening_compliance'); +}); + +it('excludes removed acknowledged findings from open risk highlights', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + Finding::factory()->for($tenant)->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => 'acknowledged', + 'subject_external_id' => 'legacy-acknowledged', + ]); + + $triagedFinding = Finding::factory()->for($tenant)->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => Finding::STATUS_TRIAGED, + 'subject_external_id' => 'canonical-triaged', + ]); + + $snapshot = seedTenantReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $openRisks = $review->sections->firstWhere('section_key', 'open_risks'); + $entries = $openRisks->render_payload['entries'] ?? []; + + expect($entries)->toHaveCount(1) + ->and($entries[0]['id'] ?? null)->toBe((int) $triagedFinding->getKey()) + ->and(collect($entries)->pluck('status')->all())->not->toContain('acknowledged'); }); diff --git a/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php b/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php index a1037a71..5deb2125 100644 --- a/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php +++ b/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php @@ -23,7 +23,7 @@ ->assertSee('Tenant Panel Entry') ->assertSee('Switch tenant') ->assertSee('Clear tenant scope') - ->assertDontSee('Search tenants…') + ->assertDontSee(__('localization.shell.search_tenants')) ->assertDontSee('admin/select-tenant'); }); diff --git a/apps/platform/tests/Unit/Badges/FindingBadgesTest.php b/apps/platform/tests/Unit/Badges/FindingBadgesTest.php index 2d3b8b9a..ba42bcb8 100644 --- a/apps/platform/tests/Unit/Badges/FindingBadgesTest.php +++ b/apps/platform/tests/Unit/Badges/FindingBadgesTest.php @@ -33,7 +33,7 @@ expect($triaged->color)->toBe('gray'); $legacyAcknowledged = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'acknowledged'); - expect($legacyAcknowledged->label)->toBe('Triaged'); + expect($legacyAcknowledged->label)->toBe('Unknown'); expect($legacyAcknowledged->color)->toBe('gray'); $inProgress = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'in_progress'); diff --git a/apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php b/apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php new file mode 100644 index 00000000..1c3e3b1a --- /dev/null +++ b/apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php @@ -0,0 +1,22 @@ +toBe([ + Finding::STATUS_NEW, + Finding::STATUS_TRIAGED, + Finding::STATUS_IN_PROGRESS, + Finding::STATUS_REOPENED, + ]); + + expect(Finding::openStatusesForQuery())->toBe(Finding::openStatuses()); +}); + +it('does not treat acknowledged as a canonical open or terminal status', function (): void { + expect(Finding::canonicalizeStatus('acknowledged'))->toBe('acknowledged'); + expect(Finding::isOpenStatus('acknowledged'))->toBeFalse(); + expect(Finding::isTerminalStatus('acknowledged'))->toBeFalse(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php b/apps/platform/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php index 13600444..08b5bd98 100644 --- a/apps/platform/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php +++ b/apps/platform/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php @@ -9,15 +9,14 @@ $types = app(OperationLifecyclePolicy::class)->coveredTypeNames(); expect($types)->toBe([ - 'baseline_capture', - 'baseline_compare', - 'inventory_sync', + 'baseline.capture', + 'baseline.compare', + 'inventory.sync', 'policy.sync', - 'policy.sync_one', - 'entra_group_sync', - 'directory_role_definitions.sync', + 'directory.groups.sync', + 'directory.role_definitions.sync', 'backup_set.update', - 'backup_schedule_run', + 'backup.schedule.execute', 'restore.execute', 'tenant.review_pack.generate', 'tenant.review.compose', @@ -28,19 +27,19 @@ it('requires direct failed-job bridges for lifecycle policy entries that declare them', function (): void { $validator = app(OperationLifecyclePolicyValidator::class); - expect($validator->jobUsesDirectFailedBridge('baseline_capture'))->toBeTrue() - ->and($validator->jobUsesDirectFailedBridge('baseline_compare'))->toBeTrue() - ->and($validator->jobUsesDirectFailedBridge('inventory_sync'))->toBeTrue() + expect($validator->jobUsesDirectFailedBridge('baseline.capture'))->toBeTrue() + ->and($validator->jobUsesDirectFailedBridge('baseline.compare'))->toBeTrue() + ->and($validator->jobUsesDirectFailedBridge('inventory.sync'))->toBeTrue() ->and($validator->jobUsesDirectFailedBridge('policy.sync'))->toBeTrue() ->and($validator->jobUsesDirectFailedBridge('tenant.review.compose'))->toBeTrue() - ->and($validator->jobUsesDirectFailedBridge('backup_schedule_run'))->toBeFalse(); + ->and($validator->jobUsesDirectFailedBridge('backup.schedule.execute'))->toBeFalse(); }); it('requires explicit timeout and fail-on-timeout declarations for covered jobs', function (): void { $validator = app(OperationLifecyclePolicyValidator::class); - expect($validator->jobTimeoutSeconds('baseline_capture'))->toBe(300) - ->and($validator->jobFailsOnTimeout('baseline_capture'))->toBeTrue() + expect($validator->jobTimeoutSeconds('baseline.capture'))->toBe(300) + ->and($validator->jobFailsOnTimeout('baseline.capture'))->toBeTrue() ->and($validator->jobTimeoutSeconds('backup_set.update'))->toBe(240) ->and($validator->jobFailsOnTimeout('backup_set.update'))->toBeTrue() ->and($validator->jobTimeoutSeconds('restore.execute'))->toBe(420) diff --git a/apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php b/apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php new file mode 100644 index 00000000..f172bc28 --- /dev/null +++ b/apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php @@ -0,0 +1,22 @@ +toBe([ + Finding::STATUS_NEW => 'New', + Finding::STATUS_TRIAGED => 'Triaged', + Finding::STATUS_IN_PROGRESS => 'In progress', + Finding::STATUS_REOPENED => 'Reopened', + Finding::STATUS_RESOLVED => 'Resolved', + Finding::STATUS_CLOSED => 'Closed', + Finding::STATUS_RISK_ACCEPTED => 'Risk accepted', + ]); +}); + +it('does not offer acknowledged as a legacy findings filter option', function (): void { + expect(FilterOptionCatalog::findingStatuses())->not->toHaveKey('acknowledged'); +}); \ No newline at end of file diff --git a/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php b/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php index 938561ac..68a556de 100644 --- a/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php +++ b/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php @@ -15,6 +15,7 @@ use App\Support\Navigation\CanonicalNavigationContext; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\OperationRunType; use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\TenantTriageReviewFingerprint; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -64,6 +65,7 @@ OperationRun::factory() ->forTenant($bravoTenant) ->create([ + 'type' => OperationRunType::InventorySync->value, 'status' => OperationRunStatus::Queued->value, 'outcome' => OperationRunOutcome::Pending->value, 'created_at' => now()->subMinutes(6), diff --git a/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetryRecorderTest.php b/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetryRecorderTest.php index a77ee8cf..2ac6efcb 100644 --- a/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetryRecorderTest.php +++ b/apps/platform/tests/Unit/Support/ProductTelemetry/ProductTelemetryRecorderTest.php @@ -53,6 +53,29 @@ ]); }); +it('records a tenant-owned usage event for an archived tenant', function () { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->for($workspace)->archived()->create(); + $user = User::factory()->create(); + + $event = app(ProductTelemetryRecorder::class)->record( + eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED, + workspaceId: (int) $workspace->getKey(), + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + subjectType: 'tenant_onboarding_session', + subjectId: 99, + metadata: [ + 'checkpoint_key' => 'verify_access', + 'lifecycle_state' => 'draft', + ], + ); + + expect($event->workspace_id)->toBe((int) $workspace->getKey()) + ->and($event->tenant_id)->toBe((int) $tenant->getKey()) + ->and($event->feature_area)->toBe('onboarding'); +}); + it('rejects unknown event names before writing telemetry rows', function () { $workspace = Workspace::factory()->create(); $tenant = Tenant::factory()->for($workspace)->create(); diff --git a/specs/254-remove-acknowledged-compat/checklists/requirements.md b/specs/254-remove-acknowledged-compat/checklists/requirements.md new file mode 100644 index 00000000..8b520791 --- /dev/null +++ b/specs/254-remove-acknowledged-compat/checklists/requirements.md @@ -0,0 +1,48 @@ +# Specification Quality Checklist: Remove Legacy Acknowledged Finding Status Compatibility + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-29 +**Feature**: specs/254-remove-acknowledged-compat/spec.md + +## Content Quality + +- [x] No language/framework/API design leakage; concrete repo surfaces, status constants, capability keys, and shared helpers are named only because this cleanup removes those exact repo-visible compatibility seams. +- [x] Focused on user value and business needs +- [x] Written primarily for product and review stakeholders, with bounded repo-specific terminology only where the cleanup target would otherwise stay ambiguous +- [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 stay outcome-oriented, with bounded repo-specific seams named only where they are required to define the cleanup target +- [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 defines measurable outcomes in Success Criteria and maps them to explicit proof tasks for implementation-time validation +- [x] No unintended implementation design leakage remains beyond the explicit cleanup special-case for named repo-visible compatibility seams + +## Test Governance Review + +- [x] Lane fit is explicit: the package uses `fast-feedback` and `confidence`, plus bounded `heavy-governance` guard coverage so shared status-badge and filter drift cannot silently reintroduce acknowledged semantics. +- [x] No new browser or heavy-governance family is introduced; retained guard coverage stays explicit and limited to shared findings status seams. +- [x] Suite-cost outcome is net-neutral to slightly negative: acknowledged-only compatibility expectations should be consolidated or deleted rather than widening shared defaults. + +## Review Outcome + +- [x] Review outcome class: `acceptable-special-case` +- [x] Workflow outcome: `keep` +- [x] Review-note location is explicit: the guardrail and lane-fit notes live in `spec.md`, the checklist, and the final preparation report. + +## Notes + +- The spec intentionally names concrete findings status constants, capability aliases, shared catalogs, and summary builders because the product value of this slice is removing those exact compatibility seams from repo truth. +- Verification-check acknowledgement and onboarding acknowledgement remain explicit non-goals so the cleanup cannot expand into a broader terminology rewrite. +- Validation pass complete: no clarification markers remain, the slice stays LEAN-001-compliant, and tenant-owned findings continue to treat `workspace_id` plus `tenant_id` as required anchors. \ No newline at end of file diff --git a/specs/254-remove-acknowledged-compat/contracts/findings-acknowledged-compat-removal.contract.yaml b/specs/254-remove-acknowledged-compat/contracts/findings-acknowledged-compat-removal.contract.yaml new file mode 100644 index 00000000..b34c02b0 --- /dev/null +++ b/specs/254-remove-acknowledged-compat/contracts/findings-acknowledged-compat-removal.contract.yaml @@ -0,0 +1,121 @@ +version: 1 +kind: findings-acknowledged-compat-removal + +scope: + goal: remove productive acknowledged compatibility from findings workflow truth only + non_goals: + - findings lifecycle backfill runtime-surface removal + - creation-time finding invariant hardening + - broader findings lifecycle redesign + - verification acknowledgement cleanup + - onboarding acknowledgement cleanup + - restore impact acknowledgement cleanup + - migration or fallback-reader preservation + +canonical_status_contract: + active_open: + - new + - triaged + - in_progress + - reopened + terminal: + - resolved + - closed + - risk_accepted + removed_active_status: + - acknowledged + +shared_seams: + model_and_workflow: + owner_files: + - apps/platform/app/Models/Finding.php + - apps/platform/app/Services/Findings/FindingWorkflowService.php + - apps/platform/app/Policies/FindingPolicy.php + requirements: + - no productive findings workflow helper writes or expects acknowledged + - open-status query helpers collapse onto the canonical active-open set only + badge_and_filter_catalogs: + owner_files: + - apps/platform/app/Support/Badges/Domains/FindingStatusBadge.php + - apps/platform/app/Support/Filament/FilterOptionCatalog.php + requirements: + - no badge label exposes acknowledged or legacy acknowledged + - no findings filter offers acknowledged as a current workflow state + capabilities_and_roles: + owner_files: + - apps/platform/app/Support/Auth/Capabilities.php + - apps/platform/app/Services/Auth/RoleCapabilityMap.php + requirements: + - tenant_findings.acknowledge is removed + - surviving findings capability language stays canonical and tenant-scoped + tenant_findings_surfaces: + routes: + - /admin/t/{tenant}/findings + - /admin/t/{tenant}/findings/{record} + owner_files: + - apps/platform/app/Filament/Resources/FindingResource.php + - apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php + - apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php + requirements: + - no visible findings workflow affordance presents acknowledged as current work + findings_derived_consumers: + owner_files: + - apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php + - apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php + - apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php + - apps/platform/app/Support/Baselines/BaselineCompareStats.php + - apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php + - apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php + - apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php + - apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php + - apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php + - apps/platform/app/Services/Baselines/BaselineAutoCloseService.php + - apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php + requirements: + - counts, previews, review disclosures, diagnostics, and alerts use the same canonical open-status set as findings surfaces + - no productive findings-derived consumer treats acknowledged as current work + +retained_behavior: + findings_workflow_actions: + - triage + - start_progress + - assign + - resolve + - close + - reopen + - request_exception + - risk_accept + guarantees: + - existing findings lifecycle outcomes remain otherwise unchanged + - no new workflow state or replacement compatibility path is introduced + +non_finding_domains: + untouched: + - verification check acknowledgement + - onboarding verification acknowledgement + - restore impact acknowledgement + +legacy_data_posture: + findings_table: + - acknowledged columns may remain in schema for now without preserving active runtime semantics + migrations: + - no new migration or persisted compatibility artifact is allowed in this slice + +validation_expectations: + no_new_persistence: + - no file under apps/platform/database/migrations may change + - no alias table, persisted mapping, or fallback reader may be introduced + absence_proof: + - no productive findings surface exposes acknowledged as current workflow status + - no productive findings-derived consumer exposes acknowledged as current work + - no findings capability alias remains for acknowledge semantics + regression_proof: + - canonical findings workflow actions still behave unchanged + - non-finding acknowledgement domains remain untouched + lane_classification: + required: + - fast-feedback + - confidence + - heavy-governance + excluded: + - browser \ No newline at end of file diff --git a/specs/254-remove-acknowledged-compat/data-model.md b/specs/254-remove-acknowledged-compat/data-model.md new file mode 100644 index 00000000..0160f0c1 --- /dev/null +++ b/specs/254-remove-acknowledged-compat/data-model.md @@ -0,0 +1,103 @@ +# Data Model — Remove Legacy Acknowledged Finding Status Compatibility + +**Spec**: [spec.md](spec.md) + +This feature is subtractive. It introduces no new persisted truth and no migration. The data-model impact is the removal of one legacy findings workflow branch from productive code and the reaffirmation of the canonical findings lifecycle as the only active status contract. + +## Existing Canonical Entities Reused + +### Finding (`findings`) + +**Purpose**: Tenant-owned findings workflow truth. + +**Key fields (existing)**: +- `id` +- `workspace_id` +- `tenant_id` +- `status` +- `triaged_at` +- `in_progress_at` +- `reopened_at` +- `resolved_at` +- `closed_at` +- `risk_accepted_at` via related exception state where applicable +- `first_seen_at` +- `last_seen_at` +- `times_seen` +- `sla_days` +- `due_at` +- `acknowledged_at` +- `acknowledged_by_user_id` + +**Feature use**: +- Remains the single canonical workflow truth for findings. +- Continues to require both `workspace_id` and `tenant_id` as ownership anchors. +- Keeps the surviving active status contract: `new`, `triaged`, `in_progress`, `reopened`. +- Keeps the surviving terminal status contract: `resolved`, `closed`, `risk_accepted`. +- `acknowledged_at` and `acknowledged_by_user_id` may remain in schema for now, but they no longer justify an active workflow status, query branch, or UI affordance. + +### FindingException (`finding_exceptions`) + +**Purpose**: Existing risk-acceptance and exception truth attached to findings. + +**Feature use**: +- Remains unchanged. +- Exists only for regression protection so removing `acknowledged` does not collapse or rename risk-governance semantics. + +## Removed Active Workflow Contract + +### LegacyAcknowledgedFindingStatus (removed, non-persisted contract) + +**Previous role**: +- active status constant on `Finding` +- extra member of `openStatusesForQuery()` +- special-case filter and badge label +- capability alias and RBAC wording branch +- compatibility expectation in findings-facing tests and summary consumers + +**Removal rule**: +- no productive code path writes `acknowledged` as current findings status +- no productive code path queries `acknowledged` as part of the active open-status set +- no productive findings UI or summary consumer presents `acknowledged` as current work +- no role or capability mapping preserves `tenant_findings.acknowledge` + +## Derived Non-Persisted Contracts + +### CanonicalFindingOpenStatusSet (derived) + +**Members**: +- `new` +- `triaged` +- `in_progress` +- `reopened` + +**Consumers**: +- findings resource and inbox queries +- workspace overview and governance inbox summaries +- review/report disclosure helpers that describe current open findings work +- support-diagnostic bundles that group active findings issues +- alerts, hygiene services, and findings generators that still look up active/open findings + +### CanonicalFindingWorkflowPermissionSet (derived) + +**Purpose**: Surviving capability vocabulary for findings workflow actions. + +**Feature use**: +- remove `tenant_findings.acknowledge` +- keep surviving findings permissions and policy checks authoritative +- keep `404` versus `403` semantics unchanged for tenant-scoped findings surfaces + +## Data Ownership Notes + +- No new table, column, persisted alias, cache, or compatibility projection is introduced. +- No migration or historical data rewrite is planned. +- Review/report and support-diagnostic consumers remain derived over tenant-owned findings truth; they do not become separate persisted status stores. +- Verification-check acknowledgement, onboarding acknowledgement, and restore acknowledgement remain separate domains and are not remodeled here. + +## Removal Invariants + +- No productive code path may treat `acknowledged` as a current findings workflow status. +- No productive query helper may include `acknowledged` in the active open findings set. +- No shared badge, filter, summary, review/report disclosure, or support-diagnostic grouping may present `acknowledged` as current findings work. +- No new migration or persisted compatibility artifact may be introduced to preserve the removed branch. +- No non-finding acknowledgement domain may change as collateral damage from this cleanup. \ No newline at end of file diff --git a/specs/254-remove-acknowledged-compat/plan.md b/specs/254-remove-acknowledged-compat/plan.md new file mode 100644 index 00000000..17e80148 --- /dev/null +++ b/specs/254-remove-acknowledged-compat/plan.md @@ -0,0 +1,266 @@ +# Implementation Plan: Remove Legacy Acknowledged Finding Status Compatibility + +**Branch**: `254-remove-acknowledged-compat` | **Date**: 2026-04-29 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/254-remove-acknowledged-compat/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/254-remove-acknowledged-compat/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +- Remove productive `acknowledged` compatibility from the findings domain by collapsing canonical status, query, badge, filter, capability, policy, and workflow seams onto the surviving findings lifecycle only. +- Keep the slice subtractive and repo-based: no new state, no migration shim, no repair tooling, no broader lifecycle redesign, and no changes to verification-check or onboarding acknowledgement domains. +- Validate the cleanup through focused workflow, summary, badge or filter, capability, and guard coverage so shared findings-derived counts and operator surfaces converge on one canonical language at the same time. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing findings workflow services, shared badge and filter catalogs, capability registry, and canonical summary builders +**Storage**: PostgreSQL existing `findings`, `finding_exceptions`, `audit_logs`, `operation_runs`, and related read models only; no new persistence or migration is planned +**Testing**: Pest unit, feature, and bounded heavy-governance guard coverage +**Validation Lanes**: fast-feedback, confidence, heavy-governance +**Target Platform**: Sail-backed Laravel web application with tenant `/admin/t/{tenant}` findings surfaces and canonical `/admin` summary or inbox surfaces +**Project Type**: web +**Performance Goals**: shared query helpers, inboxes, and summary builders keep their current bounded DB-only render profile; the slice should reduce branching and suite noise rather than add overhead +**Constraints**: LEAN-001 replacement over shims; no schema drop by default; preserve current `404` versus `403` isolation semantics; no panel or provider changes; no new assets; no widening into creation-time invariant hardening, backfill-runtime-surface work, or external support handoff +**Scale/Scope**: 1 cleanup slice touching the `Finding` model and factory, findings workflow service and policy seam, shared badge and filter catalog paths, findings resource and inbox surfaces, canonical summary builders, capability and role maps, and the related findings and guard tests + +## Likely Affected Repo Surfaces + +- Canonical findings status and workflow seams: `apps/platform/app/Models/Finding.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, `apps/platform/app/Policies/FindingPolicy.php`, and `apps/platform/database/factories/FindingFactory.php` +- Shared operator vocabulary seams: `apps/platform/app/Support/Badges/Domains/FindingStatusBadge.php`, `apps/platform/app/Support/Filament/FilterOptionCatalog.php`, `apps/platform/app/Support/Auth/Capabilities.php`, and `apps/platform/app/Services/Auth/RoleCapabilityMap.php` +- Tenant findings Filament surfaces: `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, and related workflow concerns +- Findings-derived summary and review helpers still relying on shared open-status handling or explicit `acknowledged` strings: `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php`, `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` +- Query and generator consumers of `Finding::openStatusesForQuery()`: `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, `apps/platform/app/Services/Baselines/BaselineAutoCloseService.php`, `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php`, `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php`, and `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` +- Current proof surface likely requiring update or replacement: `apps/platform/tests/Feature/Models/FindingResolvedTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, `apps/platform/tests/Unit/Badges/FindingBadgesTest.php`, `apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php`, `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php`, and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` + +## Domain / Model Fit + +- `Finding` remains the single tenant-owned source of truth with required `workspace_id` and `tenant_id` anchors. No new entity, enum family, compatibility table, or derived persistence layer is introduced. +- The canonical active lifecycle stays `new`, `triaged`, `in_progress`, and `reopened`, with `resolved`, `closed`, and `risk_accepted` remaining the canonical terminal set. `acknowledged` is removed as an active status contract rather than remapped through runtime helpers. +- `openStatusesForQuery()` and related status helpers should collapse onto the canonical open-status set instead of preserving an extra compatibility branch for `acknowledged`. +- Existing `acknowledged_at` and `acknowledged_by_user_id` columns are not justification for compatibility behavior in this slice. Default plan posture is to leave schema shape unchanged and remove productive semantics only. +- Legacy factory or fixture helpers that create `acknowledged` findings should be deleted or confined to explicitly documented stale-data edge proof only if implementation later proves that is still needed. They should not remain the default way to express current workflow truth. + +## UI / Filament & Livewire Fit + +- All touched operator surfaces remain native Filament v5 on Livewire v4. No custom dashboard framework, no panel change, and no new provider registration work are needed. +- `FindingResource` already has a `view` page, so the feature does not create a Filament global-search compliance problem. No new searchable resource is introduced. +- Shared badge rendering stays on `BadgeCatalog` plus `BadgeRenderer`, and shared filter vocabulary stays on `FilterOptionCatalog`; the cleanup must remove `acknowledged` from those shared paths rather than introducing page-local label overrides. +- Findings table, detail, and inbox surfaces should remove `acknowledged` from triage or progress visibility checks, filter options, summary counts, and helper wording together so operator UI does not drift between list, detail, and summary shells. +- No new destructive action is added. Existing destructive-like finding actions remain out of scope except that any touched action surface must preserve current `->requiresConfirmation()` and server-side capability or policy enforcement. +- No panel-only or shared asset changes are planned, so deployment keeps the existing `cd apps/platform && php artisan filament:assets` expectation unchanged only for already-registered assets. + +## RBAC / Policy Fit + +- Tenant membership and workspace membership remain the isolation boundaries: non-members stay `404`, entitled members missing the surviving capability stay `403`, and no resource existence leak is introduced while cleaning status language. +- `Capabilities::TENANT_FINDINGS_ACKNOWLEDGE` is removed instead of preserved as an alias. `RoleCapabilityMap`, `FindingPolicy`, and `FindingWorkflowService` should converge on the surviving findings capabilities only. +- The feature does not add a new role, a new authorization plane, or any page-local permission dialect. It narrows existing capability vocabulary. +- Disabled helper text and action affordances should continue to rely on the existing shared UI enforcement path while referencing only canonical findings workflow permissions. + +## Audit / Logging Fit + +- No new `AuditActionId` is introduced. +- Existing findings workflow audit verbs such as triage, assign, start progress, resolve, close, reopen, and risk acceptance remain canonical. The cleanup should not revive or add a finding-acknowledged audit dialect. +- Historical pre-production audit or metadata values do not justify runtime label shims. Verification-check acknowledgement audit behavior remains untouched. + +## Migration / Data Shape Fit + +- No new migration, no historical data backfill, and no fallback reader are planned. +- Repo evidence shows the findings status is a string column and the `acknowledged` behavior lives in code, factories, and tests rather than in a database enum or required migration path. +- Default implementation posture is to leave `findings` table columns intact for now and remove productive status compatibility from code and fixtures only. If schema removal appears necessary later, that must be split or re-justified instead of silently widening this cleanup. +- Local pre-production rows that still contain `acknowledged` are not a product compatibility requirement. Dev data reset or fixture replacement is preferred over runtime support. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament plus shared badge, filter, and summary primitives +- **Shared-family relevance**: status messaging, findings workflow actions, shared badge semantics, shared filter vocabularies, canonical summary counts and previews +- **State layers in scope**: page, detail, URL-query +- **Audience modes in scope**: operator-MSP +- **Decision/diagnostic/raw hierarchy plan**: decision-first / diagnostics-second / support-raw-third +- **Raw/support gating plan**: existing capability-gated diagnostics remain unchanged; no new raw or support surface is introduced +- **One-primary-action / duplicate-truth control**: findings surfaces keep one canonical workflow language so triage and progress remain the dominant next actions without a duplicate `acknowledged` branch competing in badges, filters, or summaries +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory now; any leftover productive `acknowledged` findings seam after implementation is a blocker +- **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 removal must happen in the shared seams themselves rather than through local exemptions +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `Finding`, `FindingWorkflowService`, `FindingPolicy`, `FindingStatusBadge`, `FilterOptionCatalog`, `Capabilities`, `RoleCapabilityMap`, `FindingResource`, `ListFindings`, `MyFindingsInbox`, `WorkspaceHealthSummaryQuery`, `WorkspaceOverviewBuilder`, `GovernanceInboxSectionBuilder`, `BaselineCompareStats`, `TenantReviewSectionFactory`, and the generator or alert consumers of shared open-status helpers +- **Shared abstractions reused**: `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, shared findings status helpers, `UiEnforcement`, and the canonical capability registry +- **New abstraction introduced? why?**: none; the correct move is to remove one legacy branch from existing shared seams +- **Why the existing abstraction was sufficient or insufficient**: the shared seams are sufficient once the compatibility branch is removed centrally; they are the reason the cleanup cannot stay local to one page or test file +- **Bounded deviation / spread control**: none; any repo surface still naming productive findings `acknowledged` after the cleanup is drift and should be removed rather than wrapped + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no +- **Central contract reused**: `N/A` +- **Delegated UX behaviors**: `N/A` +- **Surface-owned behavior kept local**: existing findings and summary surfaces only; no `OperationRun` start or link semantics change +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: `N/A` +- **Platform-core seams**: existing findings, review, and governance vocabulary only +- **Neutral platform terms / contracts preserved**: existing platform and findings vocabulary remains; the cleanup narrows a finding-domain alias rather than spreading provider language +- **Retained provider-specific semantics and why**: none +- **Bounded extraction or follow-up path**: `N/A` + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first / snapshots-second: PASS - findings remain the last-observed tenant truth, and no backup or snapshot contract changes are introduced +- Read/write separation: PASS - the slice does not add a new write path; it only narrows status semantics on existing findings workflows +- Graph contract path: PASS - no Microsoft Graph contract or provider endpoint change is involved +- Deterministic capabilities: PASS - the capability registry becomes simpler by removing a stale alias instead of expanding capability derivation +- RBAC-UX and isolation: PASS - `/admin/t/{tenant}` findings surfaces remain tenant-scoped; non-members stay `404`; in-scope members missing surviving findings capability stay `403`; no raw capability strings should survive after cleanup +- Workspace isolation / tenant isolation: PASS - tenant-owned findings and derived canonical summaries keep current workspace plus tenant entitlement rules +- Destructive confirmation standard: PASS - no new destructive action is introduced; any touched destructive-like findings action must preserve current confirmation and authorization semantics +- Global search safety: PASS - `FindingResource` already has a view page, and no new searchable resource is added +- OperationRun observability and Ops-UX: PASS - no new operation type, run start surface, or run-notification path is introduced +- Data minimization: PASS - no new payload, no raw evidence expansion, and no new audit family is introduced +- Test governance (`TEST-GOV-001`): PASS - proof stays in focused unit plus feature coverage with one explicit retained heavy-governance guard layer for shared badge or filter drift +- Proportionality (`PROP-001`) and no premature abstraction (`ABSTR-001`): PASS - the plan is subtractive and introduces no new abstraction, registry, or semantic framework +- Persisted truth (`PERSIST-001`): PASS - no new table, entity, artifact, or stored compatibility layer is added +- Behavioral state (`STATE-001`): PASS - the feature removes a legacy active-workflow branch instead of adding a new state family +- UI semantics (`UI-SEM-001`) and shared pattern first (`XCUT-001`): PASS - badge, filter, workflow, and summary semantics stay on existing shared seams rather than a new interpretation layer +- Provider boundary (`PROV-001`) and few layers (`V1-EXP-001`, `LAYER-001`): PASS - no provider seam or extra layer is introduced +- Filament-native UI and planning contract: PASS - Filament v5 remains on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, and no panel or asset strategy change is required +- Asset strategy: PASS - no new panel or shared asset registration is planned; existing deploy behavior for `filament:assets` remains unchanged + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for findings status helpers, badge or filter catalog semantics, and capability-registry cleanup; Feature for findings workflow actions, resource or inbox behavior, policy outcomes, and shared summary consumers; Heavy-Governance for the explicit guard layer that blocks ad-hoc status badge or table drift +- **Affected validation lanes**: fast-feedback, confidence, heavy-governance +- **Why this lane mix is the narrowest sufficient proof**: the core risk is central semantic drift across shared helpers and operator surfaces, not browser choreography or new async behavior. Focused unit and feature coverage prove the canonical status path, while one retained guard layer ensures `acknowledged` does not reappear through shared UI seams +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Findings/FindingStatusSemanticsTest.php tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php` +- **Fixture / helper / factory / seed / context cost risks**: acknowledged-specific fixtures and factory states are likely removal targets; keep any remaining stale-data setup explicit and local instead of spreading a legacy default across helper layers +- **Expensive defaults or shared helper growth introduced?**: no; expected net-neutral to negative because the slice removes compatibility branches and stale test setup +- **Heavy-family additions, promotions, or visibility changes**: no new heavy family is planned; one existing shared guard layer remains explicit because badge and filter drift can otherwise reintroduce removed semantics silently +- **Surface-class relief / special coverage rule**: standard-native-filament and global-context-shell relief are sufficient; no browser lane is required for this cleanup +- **Closing validation and reviewer handoff**: rerun the focused commands above, verify that no productive findings seam, capability alias, badge label, filter option, or summary builder still treats `acknowledged` as current workflow truth, and verify that verification or onboarding acknowledgement domains remain unchanged +- **Budget / baseline / trend follow-up**: expected net-neutral to slightly negative because compatibility-only tests should be removed or consolidated +- **Review-stop questions**: did implementation leave `acknowledged` in a shared status helper, a policy or capability path, a badge or filter catalog, a summary builder, or a generator test family; did it widen into schema removal or non-finding acknowledgement work +- **Escalation path**: reject-or-split +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: this slice is a bounded cleanup; creation-time invariant hardening, backfill-runtime-surface removal, and external support handoff already remain explicit separate follow-up work + +## Rollout & Risk Controls + +- Implement as replacement, not aliasing. Shared helpers, capability registries, and summary builders should converge directly on canonical statuses in the same slice. +- Treat local stale `acknowledged` rows as pre-production cleanup debt, not a customer compatibility contract. Do not add fallback readers or UI labels to preserve them. +- Preserve scope boundaries aggressively: verification acknowledgement, onboarding verification acknowledgement, restore impact acknowledgement, and non-finding support acknowledgement semantics stay untouched. +- Review stop conditions should fire if implementation tries to drop schema, invent a new compatibility mapper, or widen into findings lifecycle redesign. +- Rollout is code-only and repo-local. No queue, deployment, asset, or migration sequencing is expected. + +## Project Structure + +### Documentation (this feature) + +```text +specs/254-remove-acknowledged-compat/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── checklists/ +│ └── requirements.md +├── contracts/ +│ └── findings-acknowledged-compat-removal.contract.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/Findings/ +│ │ └── Resources/FindingResource/ +│ ├── Jobs/Alerts/ +│ ├── Models/ +│ ├── Policies/ +│ ├── Services/ +│ │ ├── Auth/ +│ │ ├── Baselines/ +│ │ ├── EntraAdminRoles/ +│ │ ├── Findings/ +│ │ ├── PermissionPosture/ +│ │ └── TenantReviews/ +│ └── Support/ +│ ├── Auth/ +│ ├── Badges/ +│ ├── CustomerHealth/ +│ ├── Filament/ +│ ├── GovernanceInbox/ +│ └── Workspaces/ +├── database/ +│ └── factories/ +└── tests/ + ├── Feature/ + │ ├── Auth/ + │ ├── Findings/ + │ ├── Guards/ + │ ├── PermissionPosture/ + │ └── EntraAdminRoles/ + └── Unit/ + ├── Badges/ + └── Findings/ +``` + +**Structure Decision**: Laravel monolith. Implementation should stay inside existing findings model, workflow, policy, shared support, Filament resource or page, factory, and test directories rather than creating a new namespace or migration track. + +## Complexity Tracking + +No constitution violation is expected. If implementation later proves it needs schema removal, a compatibility shim, or a new translation layer, that is a stop condition and should be split or rejected rather than absorbed here. + +## Proportionality Review + +N/A - this slice removes a legacy active-workflow alias and a stale capability alias. It introduces no new enum, presenter, persistence, contract layer, or taxonomy. + +## Phase 0 — Research (output: `research.md`) + +- Confirm the exact productive findings seams that still use `acknowledged` and separate them from explicitly out-of-scope non-finding acknowledgement domains. +- Confirm the no-migration posture for existing `acknowledged_*` fields and local stale rows under LEAN-001. +- Confirm which summary or review helpers still encode literal `acknowledged` status expectations and therefore belong in this cleanup instead of a later lifecycle redesign. +- Confirm which existing tests should be deleted, narrowed, or rewritten rather than preserved as compatibility proof. + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +- `data-model.md` should describe the surviving canonical findings status set, the collapsed open-status query contract, the removal of the acknowledge capability alias, and the unchanged schema posture. +- `contracts/findings-acknowledged-compat-removal.contract.yaml` should capture the cleanup matrix across model helpers, workflow and policy authorization, shared badge or filter catalogs, findings resource behavior, summary consumers, and out-of-scope acknowledgement domains. +- `quickstart.md` should document the intended implementation order, validation commands, and review stop conditions for scope drift. + +## Phase 1 — Agent Context Update + +After Phase 1 artifacts are generated, update Copilot context from the plan: + +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot` + +## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`) + +- Collapse `Finding` status helpers and legacy model helpers onto the canonical status set only. +- Remove `TENANT_FINDINGS_ACKNOWLEDGE` and update role mappings, workflow authorization, and policy checks to the surviving capability set. +- Remove `acknowledged` from shared badge and filter catalogs and from findings resource or inbox workflow affordances. +- Update shared summary and generator consumers of `openStatusesForQuery()` or explicit `acknowledged` status lists so counts, previews, and auto-close behavior align with canonical statuses. +- Delete or rewrite acknowledged-compatibility tests and factories, then add focused regression proof for the surviving canonical workflow, summary alignment, and guard coverage. +- Verify that no non-finding acknowledgement domains were touched and that no migration or compatibility shim was introduced. + +## Constitution Check (Post-Design) + +Re-check target: PASS. The post-design shape must stay subtractive, keep Filament v5 on Livewire v4, leave provider registration unchanged in `apps/platform/bootstrap/providers.php`, keep `FindingResource` global-search-safe through its existing view page, add no new destructive action or asset bundle, and preserve the no-migration, no-compatibility-shim posture. diff --git a/specs/254-remove-acknowledged-compat/quickstart.md b/specs/254-remove-acknowledged-compat/quickstart.md new file mode 100644 index 00000000..f3ca9cb1 --- /dev/null +++ b/specs/254-remove-acknowledged-compat/quickstart.md @@ -0,0 +1,36 @@ +# Quickstart — Remove Legacy Acknowledged Finding Status Compatibility + +## Prereqs + +- Docker running +- Laravel Sail dependencies installed +- Existing findings, RBAC, summary, and generator test fixtures available +- Existing seeded tenant/workspace context for targeted findings workflow tests + +## Run locally + +- Start containers: `cd apps/platform && ./vendor/bin/sail up -d` +- No schema change is expected, but use the normal repo baseline before running tests: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan migrate --no-interaction` +- Run targeted tests after implementation: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php tests/Unit/Findings/FindingStatusSemanticsTest.php tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php tests/Feature/Baselines/BaselineCompareStatsTest.php tests/Feature/Alerts/SlaDueAlertTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php` +- Format after implementation: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## Manual smoke after implementation + +1. Sign in to `/admin/t/{tenant}/findings` as an entitled tenant operator and confirm the findings register and detail use canonical status badges, filters, helper text, and workflow wording only. +2. Exercise canonical findings actions such as `Triage`, `Start progress`, `Assign`, `Resolve`, `Close`, and `Risk accept` and confirm no action or helper text refers to an acknowledge alias. +3. Open the affected canonical `/admin` summary and inbox surfaces and confirm counts and previews match the same canonical open findings set as the findings register. +4. Open an in-scope tenant review, review-pack, or support-diagnostic surface that renders findings-derived open-work disclosure and confirm it does not describe `acknowledged` as current work. +5. Verify capability-driven findings gating no longer references `tenant_findings.acknowledge` while preserving existing `404` versus `403` behavior. +6. Review the diff and confirm no file under `apps/platform/database/migrations/` changed and no new persisted compatibility artifact was introduced. + +## Notes + +- Filament v5 remains on Livewire v4.0+ in this repo; the cleanup stays inside existing native Filament resources, pages, and shared support helpers. +- No panel or provider registration changes are planned; `apps/platform/bootstrap/providers.php` remains authoritative if provider work is ever needed later. +- `FindingResource` already has a view page, so the feature does not create a global-search contract issue. +- No asset changes are expected, so there is no additional `filament:assets` deployment work for this slice. +- LEAN-001 applies directly: remove compatibility branches instead of preserving aliases, fallback readers, or migrations for historical pre-production rows. \ No newline at end of file diff --git a/specs/254-remove-acknowledged-compat/research.md b/specs/254-remove-acknowledged-compat/research.md new file mode 100644 index 00000000..4a1d0d18 --- /dev/null +++ b/specs/254-remove-acknowledged-compat/research.md @@ -0,0 +1,129 @@ +# Research — Remove Legacy Acknowledged Finding Status Compatibility + +**Date**: 2026-04-29 +**Spec**: [spec.md](spec.md) + +This document records the repo-grounded planning decisions for the acknowledged-compatibility cleanup slice. All decisions assume the current pre-production LEAN-001 posture. + +## Decision 1 — Remove acknowledged semantics at shared seams, not through page-local relabeling + +**Decision**: Delete productive `acknowledged` compatibility from the shared findings seams that currently define status truth, query truth, badge vocabulary, filter vocabulary, workflow eligibility, and capability language. Do not treat this as a page-local label replacement. + +**Rationale**: +- The drift is cross-surface today: `Finding::STATUS_ACKNOWLEDGED`, `Finding::openStatusesForQuery()`, `FilterOptionCatalog`, `BadgeCatalog`, `FindingWorkflowService`, `Capabilities::TENANT_FINDINGS_ACKNOWLEDGE`, and multiple summary consumers all preserve the old vocabulary. +- A list-only or badge-only rename would leave summary counts, disabled helper text, and RBAC wording inconsistent. +- XCUT-001 requires converging on the existing shared path instead of adding local exceptions. + +**Evidence**: +- `apps/platform/app/Models/Finding.php` +- `apps/platform/app/Services/Findings/FindingWorkflowService.php` +- `apps/platform/app/Support/Filament/FilterOptionCatalog.php` +- `apps/platform/app/Support/Badges/Domains/FindingStatusBadge.php` +- `apps/platform/app/Support/Auth/Capabilities.php` +- `apps/platform/app/Services/Auth/RoleCapabilityMap.php` + +**Alternatives considered**: +- Relabel `acknowledged` to `triaged` only on findings pages. + - Rejected: shared queries, summaries, and capability guidance would still preserve conflicting truth. +- Keep a read-side compatibility mapper indefinitely. + - Rejected: LEAN-001 forbids preserving a pre-production legacy branch without a current-release need. + +## Decision 2 — Treat stale acknowledged rows and columns as pre-production residue, not as a runtime compatibility contract + +**Decision**: Keep the cleanup code-only by default. Remove productive semantics first and do not add migration shims, fallback readers, or preserved UI labels just because `acknowledged_at` or `acknowledged_by_user_id` columns still exist locally. + +**Rationale**: +- The repo is explicitly pre-production, and LEAN-001 prefers replacement or deletion over historical compatibility behavior. +- The current problem is active workflow semantics in code and tests, not an unavoidable database constraint. +- The narrowest correct implementation is to stop writing, querying, and presenting `acknowledged` as current findings truth. + +**Evidence**: +- `apps/platform/app/Models/Finding.php` +- `.specify/memory/constitution.md` (LEAN-001, PERSIST-001) +- `docs/product/spec-candidates.md` + +**Alternatives considered**: +- Add a migration or fallback reader now. + - Rejected: widens scope into persistence work not justified by current release truth. +- Preserve `legacy acknowledged` UI labels until later. + - Rejected: keeps the removed semantics productized. + +## Decision 3 — Keep findings-derived review, report, and support-diagnostic consumers in scope where they surface current open-work truth + +**Decision**: Include review/report and support-diagnostic consumers in this cleanup only where they derive current findings-open counts, disclosure text, or issue grouping from the same shared status helpers as canonical summaries. + +**Rationale**: +- Repo truth shows `TenantReviewSectionFactory` and `SupportDiagnosticBundleBuilder` still depend on acknowledged-aware status logic. +- Leaving those consumers out would preserve productive status drift even if the findings register and canonical `/admin` summaries were cleaned. +- This remains bounded because the slice is limited to findings-derived open-work semantics, not broader review-pack, evidence, or diagnostic redesign. + +**Evidence**: +- `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` +- `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` +- `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php` +- `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` + +**Alternatives considered**: +- Defer review/report and diagnostics entirely. + - Rejected: current productive consumers would still present split workflow truth. +- Broaden into review-pack or diagnostic domain redesign. + - Rejected: outside the smallest cleanup slice. + +## Decision 4 — Keep non-finding acknowledgement domains explicitly out of scope + +**Decision**: Do not rename or remove acknowledgement semantics outside the findings domain, including verification-check acknowledgement, onboarding-verification acknowledgement, and restore impact acknowledgement. + +**Rationale**: +- Those domains carry different user intent and do not prove that findings status compatibility must remain. +- Mixing them into this slice would widen terminology cleanup into unrelated workflows. +- The spec already depends on maintaining bounded ownership and avoiding accidental cross-domain churn. + +**Evidence**: +- `apps/platform/app/Services/Verification/VerificationCheckAcknowledgementService.php` +- `apps/platform/app/Filament/Support/VerificationReportViewer.php` +- `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- `apps/platform/app/Filament/Resources/RestoreRunResource.php` + +**Alternatives considered**: +- Normalize every `acknowledge*` term in the repo at once. + - Rejected: too broad and not required for the findings cleanup to be correct. + +## Decision 5 — Keep validation in focused Feature, Unit, and retained guard lanes only + +**Decision**: Prove the cleanup with focused findings workflow tests, focused summary-consumer tests, focused capability cleanup tests, and the already-retained heavy-governance guard coverage. Do not add browser coverage. + +**Rationale**: +- The business risk is shared-seam drift, not browser choreography or async execution. +- The repo already has meaningful findings, generator, summary, and guard test families that can be narrowed or rewritten. +- TEST-GOV-001 prefers the smallest proving lane mix that guards business truth. + +**Evidence**: +- `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` +- `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` +- `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php` +- `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` +- `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` + +**Alternatives considered**: +- Add browser smoke coverage. + - Rejected: low additional value for this cleanup. +- Preserve broad acknowledged-compatibility fixture families. + - Rejected: would keep the removed semantics alive in the suite. + +## Decision 6 — Remove the stale findings capability alias instead of translating it forever + +**Decision**: Delete `tenant_findings.acknowledge` from the canonical capability registry and role mappings, and converge disabled helper text and authorization expectations on the surviving findings permissions. + +**Rationale**: +- The acknowledged alias keeps RBAC language inconsistent with the canonical triage action. +- Capability drift is part of the user-visible problem in this slice, not a separate concern. +- RBAC-UX requires server-side truth to stay on the canonical capability set, not parallel aliases. + +**Evidence**: +- `apps/platform/app/Support/Auth/Capabilities.php` +- `apps/platform/app/Services/Auth/RoleCapabilityMap.php` +- `apps/platform/app/Policies/FindingPolicy.php` + +**Alternatives considered**: +- Keep the alias as an undocumented backward-compatibility seam. + - Rejected: preserves the exact semantics blocker this feature is intended to remove. \ No newline at end of file diff --git a/specs/254-remove-acknowledged-compat/spec.md b/specs/254-remove-acknowledged-compat/spec.md new file mode 100644 index 00000000..bd5e542e --- /dev/null +++ b/specs/254-remove-acknowledged-compat/spec.md @@ -0,0 +1,283 @@ +# Feature Specification: Remove Legacy Acknowledged Finding Status Compatibility + +**Feature Branch**: `254-remove-acknowledged-compat` +**Created**: 2026-04-29 +**Status**: Draft +**Input**: User description: "Prepare the next repo-based cleanup slice that removes legacy acknowledged finding-status compatibility and collapses findings workflow semantics onto canonical triaged or open handling without changing customer-facing workflow scope or reintroducing repair tooling." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot still carries two parallel findings workflow languages. The product treats `triaged` as the canonical operator meaning, but productive code, shared queries, status filters, badges, role mappings, and workflow tests still preserve `acknowledged` compatibility as if it were an active workflow truth. +- **Today's failure**: Operators and maintainers can still encounter `acknowledged` as a current finding status through shared helpers, filter options, badge labels, capability aliases, and findings-derived summary logic. That weakens workflow clarity, keeps RBAC language inconsistent, and makes shared counts and previews harder to trust. +- **User-visible improvement**: Tenant and workspace operators see one canonical findings workflow language. Status badges, filters, summary counts, helper text, and workflow actions consistently speak in `new`, `triaged`, `in_progress`, `reopened`, `resolved`, `closed`, and `risk_accepted` terms only. +- **Smallest enterprise-capable version**: Remove acknowledged compatibility end to end from productive findings status constants and helpers, shared query helpers, shared filter and badge catalogs, capability registry and role mappings, workflow-facing tests, and findings-derived summary surfaces while leaving the rest of the findings lifecycle unchanged. +- **Explicit non-goals**: No backfill-runtime-surface removal in this slice, no broader findings lifecycle redesign, no new states, no migration shim, no historical data migration, no verification-acknowledgement cleanup, no onboarding-verification terminology rewrite, and no new customer-facing workflow surface. +- **Permanent complexity imported**: Net negative. The slice removes a legacy status branch, a capability alias, catalog special-casing, and acknowledged-specific workflow expectations. The only enduring obligation is focused regression coverage that proves one canonical status path remains across findings workflows and findings-derived summaries. +- **Why now**: This candidate remains explicitly open in both product sources and is still repo-proven in productive code. It is smaller and more implementation-ready than creation-time invariant hardening, and it does not depend on an external product decision like External Support Desk / PSA Handoff. +- **Why not local**: The compatibility drift is not confined to one screen or helper. It spans the `Finding` model, workflow service, shared filter catalog, badge language, capability registry, role mappings, canonical summary builders, and workflow-facing tests. A local rename would leave inconsistent product truth in other entry points. +- **Approval class**: Cleanup +- **Red flags triggered**: Multiple micro-specs for one domain. Defense: this slice is intentionally limited to canonical status and RBAC vocabulary cleanup only, while creation-time invariants and external support handoff remain explicit follow-up candidates. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Selection Rationale + +- `Remove Legacy Acknowledged Finding Status Compatibility` is still active in [docs/product/spec-candidates.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/docs/product/spec-candidates.md#L172) and [docs/product/implementation-ledger.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/docs/product/implementation-ledger.md#L183) and remains a concrete semantics blocker instead of a speculative cleanup. +- Repo truth still shows acknowledged drift in the canonical findings model and workflow seams, including `Finding::STATUS_ACKNOWLEDGED`, `Finding::openStatusesForQuery()`, `FilterOptionCatalog::findingStatuses()`, `Capabilities::TENANT_FINDINGS_ACKNOWLEDGE`, and findings-derived summary builders. +- This slice is narrower and safer than `Enforce Creation-Time Finding Invariants` because it removes visible and shared workflow ambiguity first without widening generator hardening or recurrence rules. +- `Cross-Tenant Compare and Promotion v1` is not the next preparation target here because the repo already has refreshed Spec 043 ready for later implementation work. +- `External Support Desk / PSA Handoff` stays deferred because it still depends on a concrete external-desk target and broader commercialization workflow decisions, while this cleanup is fully repo-based today. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant + canonical-view +- **Primary Routes**: + - `/admin/t/{tenant}/findings` + - `/admin/t/{tenant}/findings/{record}` + - existing canonical `/admin` summary and inbox surfaces that derive open-finding counts or previews from shared findings queries + - existing findings table and filter surfaces that use shared finding-status catalog options + - existing tenant review, review-pack, and support-diagnostic surfaces only where they render findings-derived open-work summaries, counts, or disclosure text +- **Data Ownership**: + - Tenant-owned `Finding` and related `FindingException` truth remain canonical and keep required `workspace_id` plus `tenant_id` anchors. + - Workspace, canonical summary, review/report, and diagnostic consumers stay derived over tenant-owned findings truth; this feature introduces no new persistence, no mirror entity, and no migration data store. + - Historical pre-production findings rows do not justify a compatibility table, alias, or fallback reader. +- **RBAC**: + - Tenant membership remains the isolation boundary for findings visibility and surviving finding workflow mutations. + - Canonical findings workflow permissions stay capability-first and tenant-scoped; `tenant_findings.acknowledge` is removed rather than preserved as an alias. + - Non-members remain deny-as-not-found and entitled members missing surviving findings capabilities remain forbidden on the affected mutation paths. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: Canonical `/admin` summary and inbox surfaces that launch from tenant context continue to prefilter to the current tenant, but they must do so with canonical open-status handling only and without an acknowledged compatibility branch. +- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace membership and visible-tenant filtering remain authoritative on canonical summary surfaces. The cleanup must not widen findings queries or previews beyond entitled tenants while removing acknowledged compatibility. + +## 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 +- **Interaction class(es)**: status messaging, workflow helper text, shared badges, shared filter vocabularies, canonical summary counts and previews, capability language +- **Systems touched**: `App\Models\Finding`, `App\Services\Findings\FindingWorkflowService`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\Auth\Capabilities`, `App\Services\Auth\RoleCapabilityMap`, existing findings resource surfaces, existing findings-derived canonical summary builders, `App\Services\TenantReviews\TenantReviewSectionFactory`, and `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder` +- **Existing pattern(s) to extend**: shared finding-status helpers, shared filter and badge catalogs, existing findings workflow actions, existing capability registry, and existing canonical summary builders remain the only supported paths +- **Shared contract / presenter / builder / renderer to reuse**: `Finding::openStatuses()`, shared `BadgeCatalog` finding-status semantics, `FilterOptionCatalog::findingStatuses()`, the canonical capability registry, and existing summary builders that already derive open findings from shared helpers +- **Why the existing shared path is sufficient or insufficient**: the shared paths are sufficient once the legacy acknowledged branch is removed. They are the reason this cleanup must land centrally instead of through page-local exceptions or label overrides. +- **Allowed deviation and why**: none +- **Consistency impact**: triage wording, open counts, badges, filters, review/report disclosure text, diagnostic issue summaries, disabled helper text, and role guidance must all converge on the same canonical finding-status language in the same slice. +- **Review focus**: reviewers must verify that no productive code path, shared filter, badge label, capability alias, review/report disclosure, diagnostic summary, or workflow-facing test still treats `acknowledged` as current findings workflow truth. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: no +- **Shared OperationRun UX contract/layer reused**: `N/A` +- **Delegated start/completion UX behaviors**: `N/A` +- **Local surface-owned behavior that remains**: existing findings and summary surfaces remain read/write or read-only according to their current workflows; no `OperationRun` start semantics are introduced or removed by this status cleanup. +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +N/A - no shared provider or platform seam is widened. This slice only removes legacy findings workflow compatibility inside the existing findings domain. + +## 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 register and detail: remove acknowledged wording from statuses, filters, and workflow affordances | yes | Native Filament + shared workflow primitives | row actions, header actions, badges, filters | list, detail, action state | no | Canonical triage language only | +| Canonical findings-derived summaries: governance inbox, workspace overview, customer health, and similar previews use canonical open-status handling only | yes | Native Filament + shared summary builders | dashboard cards, inbox previews, counters, drilldowns | page, widget, query state | no | Summary counts and previews stop carrying a hidden acknowledged branch | +| Shared findings status filters and badges: remove legacy acknowledged option and label | yes | Shared badge and filter catalog primitives | status messaging, filter vocabulary, badge semantics | catalog, table filter state | no | No `legacy acknowledged` affordance remains on productive findings surfaces | + +## 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 register and detail | Primary Decision Surface | Decide how to triage or continue work on a finding | canonical status, severity, due or SLA signals, responsibility, and canonical workflow actions | evidence, history, related runs, and exception detail after opening the finding | Primary because this is where tenant operators act on findings today | Keeps findings work centered on one canonical lifecycle path | Removes a parallel acknowledged label that competes with the real next action | +| Canonical findings-derived summaries | Secondary Context Surface | Decide where follow-up exists before drilling into findings work | counts, previews, and urgency signals derived from canonical open statuses only | the findings register or detail after navigation | Not primary because these surfaces route operators into findings work rather than owning the mutations themselves | Keeps overview or inbox surfaces honest about what is actually open | Removes mismatched counts and pseudo-open summary branches | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Tenant findings register and detail | operator-MSP | canonical findings status, responsibility, due state, and workflow affordances | history, evidence, exception detail, and related runs after opening the record | raw or support-only detail remains on existing deeper routes and capability gates | `Triage finding` or continue canonical workflow | low-level evidence and audit detail stay secondary | status is stated once in canonical terms and deeper sections add evidence rather than alternate vocabulary | +| Canonical findings-derived summaries | operator-MSP | canonical counts, previews, and urgency signals only | secondary drilldowns to the findings register and detail | raw evidence is never the default content on the summary surface | `Open findings` | detailed evidence and audit context stay on deeper surfaces | summaries describe open work once and rely on the findings register for detailed truth | + +## 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 register and detail | List / Table / Bulk | CRUD / List-first Resource | Open a finding and continue the canonical workflow | full-row navigation to finding detail | required | existing row `More` actions and detail-header actions only | existing destructive-like actions remain in grouped or detail-header placements | `/admin/t/{tenant}/findings` | `/admin/t/{tenant}/findings/{record}` | tenant context, status filters, responsibility, due state | Findings / Finding | canonical findings workflow state and urgency | none | +| Canonical findings-derived summaries | Monitoring / Queue / Workbench | Context-first summary and preview shell | open a filtered findings view for the relevant tenant or queue | card or preview drilldown into the findings register | forbidden | secondary links only | none | existing canonical `/admin` summary and inbox pages | `/admin/t/{tenant}/findings` | workspace context, tenant filter, preview scope | Findings follow-up / Findings follow-up | where open work exists under the canonical status set | 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 register and detail | Tenant operator | Decide how to triage, assign, continue, resolve, close, or risk-accept a finding | List/detail | What should I do next with this finding? | canonical status, severity, responsibility, due or SLA state, and current workflow affordances | evidence, exception history, audit context, related operations | lifecycle, urgency, responsibility, governance validity | TenantPilot only for the existing findings workflow actions | Triage, Start progress, Assign, Resolve, Risk accept | existing destructive-like workflow actions only | +| Canonical findings-derived summaries | Workspace or portfolio operator | Decide where follow-up exists before drilling into findings work | Summary and preview | Where is open findings work waiting right now? | canonical counts, previews, due urgency, and tenant context | deeper findings detail after navigation | lifecycle and urgency only | none on the summary surface itself | Open findings | none | + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Feature, Unit, Heavy-Governance +- **Validation lane(s)**: fast-feedback, confidence, heavy-governance +- **Why this classification and these lanes are sufficient**: focused feature coverage proves canonical findings workflows and findings-derived summaries stop exposing acknowledged semantics, while narrow unit coverage proves the shared status helper, filter catalog, and capability cleanup stay centralized. One retained heavy-governance guard layer remains appropriate because status-like badge/filter drift can reappear through shared seams even after the main behavior is corrected. +- **New or expanded test families**: focused findings workflow cleanup coverage, focused findings-summary consistency coverage, and bounded filter or capability cleanup coverage. No browser family is introduced. +- **Fixture / helper cost impact**: low and likely net-neutral to slightly negative. Acknowledged-only fixtures and compatibility expectations should be consolidated or deleted instead of adding new heavy setup. +- **Heavy-family visibility / justification**: retained shared guard coverage for status-like tokens and filter-catalog usage remains explicit so a local reintroduction of acknowledged semantics cannot survive through shared UI seams. No new heavy-governance family is introduced. +- **Special surface test profile**: standard-native-filament, global-context-shell +- **Standard-native relief or required special coverage**: standard Filament and domain coverage are sufficient for the findings resource and canonical summaries. Required extra proof is shared guard coverage for badge and filter drift. +- **Reviewer handoff**: reviewers must confirm that acknowledged disappears together from the model helper, workflow rules, shared filter options, shared badge language, role/capability vocabulary, and summary counts or previews. They must also confirm that verification acknowledgement and onboarding acknowledgement remain untouched. +- **Budget / baseline / trend impact**: net-neutral to slightly negative because the slice should remove acknowledged-only compatibility expectations rather than widen the suite. +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Findings/FindingStatusSemanticsTest.php tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php` + +## RBAC / Isolation Considerations + +- Tenant findings mutations remain tenant-scoped and follow existing deny-as-not-found versus forbidden semantics: non-members remain `404`, entitled members missing a surviving findings capability remain `403` on mutation. +- Canonical `/admin` summary and inbox surfaces continue to derive only from tenants the actor can see in the current workspace. Removing acknowledged compatibility must not broaden canonical previews or counts beyond the current visible-tenant boundary. +- `tenant_findings.acknowledge` is removed rather than preserved as an alias. Canonical findings workflow language stays capability-first and centered on the surviving findings capabilities. +- This slice does not add a new role family, a new authorization plane, or a new hidden compatibility bypass. + +## Auditability + +- No new audit action ID is introduced by this cleanup. +- Existing findings workflow audit actions such as triage, assignment, in-progress, resolve, close, reopen, and risk acceptance remain the canonical audit language for surviving workflow actions. +- Pre-production historical acknowledgement fields or audit rows do not justify a compatibility renderer, label shim, or preserved active-workflow vocabulary. +- The implementation should ensure that operator-facing surfaces do not continue to advertise `acknowledged` as a current workflow outcome merely because historical audit or fixture data once used it. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Use One Canonical Findings Workflow Language (Priority: P1) + +As a tenant operator, I want findings surfaces to speak in one canonical workflow language so I can triage and continue work without guessing whether `acknowledged` and `triaged` still mean different things. + +**Why this priority**: This is the core user-facing value of the cleanup. If findings still speak two different workflow languages, the slice has failed. + +**Independent Test**: Open the findings register and detail for a tenant with open findings and verify that statuses, filters, badges, and workflow affordances use canonical findings vocabulary only. + +**Acceptance Scenarios**: + +1. **Given** an entitled tenant operator opens the findings register, **When** the page renders, **Then** no current status badge, filter option, or helper text exposes `acknowledged` as a valid findings workflow state. +2. **Given** an open finding is ready for triage or progress, **When** the operator uses the surviving workflow affordances, **Then** the workflow uses canonical `triaged` semantics rather than an acknowledge alias. +3. **Given** a finding has already reached a terminal state, **When** the operator opens it, **Then** the terminal states remain unchanged and no new replacement state is introduced. + +--- + +### User Story 2 - Keep Summary Counts, Reports, and Diagnostics Honest (Priority: P1) + +As a workspace or portfolio operator, I want shared counts, previews, review/report disclosures, and diagnostic summaries to match the findings register so I can trust what is actually open without a hidden acknowledged branch skewing the numbers. + +**Why this priority**: Shared summary drift is one of the main reasons this cleanup cannot stay local. If summaries, review/report disclosures, diagnostics, and lists diverge, operators lose trust in the overview surfaces. + +**Independent Test**: Compare the findings register with the affected canonical summary and inbox surfaces plus findings-derived review/report and support-diagnostic consumers for the same tenant or workspace context and verify that the same canonical open-status set drives all of them. + +**Acceptance Scenarios**: + +1. **Given** a canonical summary surface shows open findings work for a tenant, **When** the operator drills into the findings register, **Then** the summary counts and previews align with the same canonical open-status set. +2. **Given** the operator changes tenant or workspace context, **When** canonical summaries reload, **Then** they continue to derive findings work only from canonical open statuses and the currently entitled tenant set. +3. **Given** a tenant review, review-pack, or support-diagnostic surface renders findings-derived open-work disclosure, **When** the operator opens that surface, **Then** the disclosure text, counts, and issue grouping use the same canonical open-status set and do not present `acknowledged` as current work. + +--- + +### User Story 3 - Keep RBAC Language Canonical (Priority: P2) + +As a tenant manager or owner, I want role guidance and capability-driven findings actions to use one canonical permission language so workflow help and disabled states stay understandable. + +**Why this priority**: The acknowledged alias is not only a status problem. It also keeps role and action guidance inconsistent across the same workflow. + +**Independent Test**: Review capability-driven findings surfaces and role expectations after the cleanup and verify that they reference surviving findings capabilities only. + +**Acceptance Scenarios**: + +1. **Given** a tenant member can triage findings, **When** the findings UI explains or gates the action, **Then** it refers to the canonical triage capability and not an acknowledge alias. +2. **Given** a tenant member lacks the required surviving capability, **When** they attempt the affected findings action, **Then** the existing forbidden behavior remains and no acknowledge-specific permission branch is used. + +### Edge Cases + +- Local or historical pre-production rows may still contain `acknowledged`; this slice does not add a migration shim, fallback reader, or preserved UI label to keep that branch alive. +- Removing acknowledged from shared helpers must update list surfaces, summary counts, preview queries, filters, and badges together; otherwise the cleanup would create a new mismatch instead of removing one. +- Verification-check acknowledgement and onboarding-verification acknowledgement are separate domains and must not be renamed or removed as collateral damage in this slice. +- Resolved, closed, and risk-accepted findings behavior remains distinct and must not be collapsed while removing the acknowledged compatibility path. + +## Requirements *(mandatory)* + +**Constitution alignment (LEAN-001 / STATE-001 / SPEC-GATE-001):** This is a pre-production cleanup slice. It removes a legacy findings workflow branch rather than introducing new state, persistence, or abstraction. Compatibility shims, fallback readers, historical fixture preservation, and capability aliases are out of scope. + +**Constitution alignment (XCUT-001 / BADGE-001):** Because the drift survives in shared status helpers, shared filter catalogs, shared badge language, and shared summary builders, the cleanup must land through the shared paths themselves. No page-local override or secondary presenter may keep acknowledged alive. + +**Constitution alignment (RBAC-UX):** Tenant membership and capability rules stay unchanged except for removing the acknowledged alias from the findings capability vocabulary. Non-members remain `404`; entitled members missing the surviving capability remain `403` on mutations. + +**Constitution alignment (TEST-GOV-001):** Proof stays in focused feature, unit, and bounded heavy-governance guard coverage. The slice should remove acknowledged-only expectations rather than creating a broader or heavier new test family. + +**Constitution alignment (UI-FIL-001 / UI-NAMING-001 / DECIDE-001 / OPSURF-001):** Findings surfaces remain native Filament or shared summary surfaces. Canonical findings vocabulary must stay consistent across badges, filters, helper text, row actions, disabled states, and drilldown summaries without introducing a new local status language. + +### Functional Requirements + +- **FR-254-001**: The system MUST retire `acknowledged` as a productive findings workflow status and remove any status helper that treats it as a current canonical findings state. +- **FR-254-002**: Shared open-status query helpers and findings-derived summary builders MUST rely on the canonical open findings status set only and MUST NOT preserve a hidden acknowledged compatibility branch. +- **FR-254-003**: Shared findings filter catalogs, status badges, and related helper text MUST stop exposing `acknowledged` or `legacy acknowledged` as a valid findings workflow affordance. +- **FR-254-004**: Findings workflow actions and guards MUST authorize and mutate against canonical triage semantics only; the active findings workflow must not require or preserve an acknowledge alias. +- **FR-254-005**: The canonical capability registry, role mappings, and workflow-facing authorization expectations MUST remove `tenant_findings.acknowledge` rather than keeping it as a stale alias. +- **FR-254-006**: Productive code paths and workflow-facing tests MUST stop writing, expecting, or advertising `acknowledged` as a valid current findings workflow status. +- **FR-254-007**: Existing findings flows remain functional and in scope only for regression protection across `new`, `triaged`, `in_progress`, `reopened`, `resolved`, `closed`, and `risk_accepted` outcomes. +- **FR-254-008**: The feature MUST NOT reintroduce repair tooling, backfill semantics, new workflow states, migration shims, fallback readers, or historical compatibility logic to preserve the removed acknowledged branch. +- **FR-254-009**: The feature MUST NOT alter verification-check acknowledgement, onboarding-verification acknowledgement, or other non-finding acknowledgement domains unless a path directly depends on findings status compatibility, in which case that dependency must be removed instead of widening the slice. +- **FR-254-010**: Tenant-owned findings keep existing `workspace_id` plus `tenant_id` ownership anchors; no new persisted alias, auxiliary mapping table, or compatibility truth is introduced. + +## 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 resource list/detail | `app/Filament/Resources/FindingResource.php` and related pages | no acknowledge-named action or helper text remains; surviving workflow utilities keep canonical wording | full-row click to finding detail remains canonical | existing canonical findings workflow actions only | existing grouped bulk actions only, with no acknowledged vocabulary | existing empty state remains unchanged | existing detail-header workflow actions keep canonical wording only | `N/A` | yes, unchanged for surviving workflow actions | Remove acknowledged vocabulary from filters, badges, disabled helper text, and action guidance without introducing a replacement action | +| Canonical findings summaries and inbox shells | existing `/admin` pages and widgets using shared findings summary builders | no new header actions; drilldown links only | same-page cards, counters, or preview links remain canonical | none | none | existing empty states remain, but they must not mention acknowledged compatibility | `N/A` | `N/A` | no new audit event | This slice updates counts, previews, and wording only | + +### Key Entities *(include if feature involves data)* + +- **Canonical finding status**: The current findings lifecycle language used on productive findings surfaces and queries. After this cleanup it consists only of the surviving canonical statuses already present in the findings workflow. +- **Findings-derived summary surface**: Any canonical `/admin` overview, inbox, widget, or preview surface that derives open findings work from the shared finding-status helper rather than from a local list of status strings. +- **Findings capability mapping**: The shared capability and role-mapping truth that determines which tenant members can use the surviving findings workflow actions. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Zero productive findings filters, badges, helper texts, or workflow affordances expose `acknowledged` as a current findings workflow status. +- **SC-002**: Zero supported findings permissions or role mappings expose `tenant_findings.acknowledge` after the cleanup. +- **SC-003**: All in-scope findings-derived summary surfaces and findings register surfaces use the same canonical open-status set during regression validation. +- **SC-004**: Representative regression proof still passes for the surviving findings workflow from `new` through `triaged`, `in_progress`, `resolved`, and `risk_accepted` outcomes without introducing a replacement compatibility branch. + +## Dependencies + +- The canonical findings model and workflow seams where acknowledged compatibility still survives, including the shared findings status helper, workflow service, filter catalog, and capability registry. +- Existing findings-derived summary builders that currently rely on shared open-status helpers for inbox, health, and preview surfaces. +- Existing findings resource and workflow-facing tests that still preserve or assert acknowledged semantics. + +## Assumptions + +- The current repo truth treats `triaged` as the canonical operator-facing findings workflow semantics and keeps `acknowledged` only as cleanup debt. +- LEAN-001 still applies because the product remains pre-production; historical or local findings rows do not justify compatibility behavior in this slice. +- Spec 253 already covers the adjacent backfill-runtime-surface cleanup, so this slice should not reopen that work while cleaning status semantics. +- Cross-Tenant Compare and Promotion is already refreshed as Spec 043 and is therefore not the next open preparation target here. +- Verification-check acknowledgement remains a separate domain and must not be pulled into this findings cleanup. + +## Risks + +- Hidden acknowledged residues may still survive in shared summary builders, status badges, or old test fixtures even after the main findings workflow seam is cleaned. +- Local or stale pre-production data containing acknowledged may surface unexpected failures if implementation removes compatibility before all relevant fixtures and productive write paths are updated together. +- Overbroad cleanup could accidentally touch verification or onboarding acknowledgement semantics, which would violate the intended slice boundary. + +## Out of Scope + +- Removing or revisiting the already-separated backfill-runtime-surface cleanup slice +- Enforcing creation-time finding invariants or generator hardening beyond what is needed to stop preserving acknowledged compatibility +- Broader findings lifecycle redesign, new workflow states, or new customer-facing workflow surfaces +- Historical data migration, translation helpers, fallback readers, or compatibility-specific test preservation +- Verification-check acknowledgement, onboarding acknowledgement UX, or non-finding acknowledgement domains +- External Support Desk / PSA Handoff or other commercialization workflow work + +## Follow-up Candidates + +1. `Enforce Creation-Time Finding Invariants` remains the next findings hardening candidate after this semantics cleanup because generator and recurrence guarantees still need explicit protection. +2. `External Support Desk / PSA Handoff` remains an explicit deferred candidate for commercialization flow maturity once the repo names a concrete external desk target. +3. `Cross-Tenant Compare and Promotion v1` remains covered by refreshed Spec 043 and should continue on that track instead of being reopened inside this cleanup slice. diff --git a/specs/254-remove-acknowledged-compat/tasks.md b/specs/254-remove-acknowledged-compat/tasks.md new file mode 100644 index 00000000..f956103a --- /dev/null +++ b/specs/254-remove-acknowledged-compat/tasks.md @@ -0,0 +1,238 @@ +# Tasks: Remove Legacy Acknowledged Finding Status Compatibility + +**Input**: Design documents from `/specs/254-remove-acknowledged-compat/` +**Prerequisites**: `plan.md`, `spec.md`, `checklists/requirements.md` + +**Tests (TEST-GOV-001)**: REQUIRED (Pest). Keep proof in the targeted `fast-feedback` and `confidence` lanes named in `specs/254-remove-acknowledged-compat/plan.md`, plus the retained `heavy-governance` guards already called out there. Prefer focused new proof in `apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php`, `apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php`, `apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php`, and `apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php`, then prove summary convergence through the existing downstream customer-health, governance-inbox, baseline, alert, tenant-review, and support-diagnostics tests instead of widening the suite. +**Operations**: This slice does not add or change an `OperationRun` start family, does not add a new audit action ID, and must not introduce migration shims, fallback readers, or repair tooling. +**RBAC**: Preserve current `/admin/t/{tenant}` tenant isolation, deny-as-not-found `404` for non-members or out-of-scope users, and `403` for in-scope capability failures on surviving findings actions. Remove `tenant_findings.acknowledge` from the capability registry and role mappings without widening any unrelated authorization behavior. +**UI / Surface Guardrails**: This is a `review-mandatory` cleanup across native Filament findings surfaces and canonical `/admin` summary or inbox shells. Keep `standard-native-filament` relief for the tenant findings resource and `global-context-shell` proof for workspace summaries, and do not add panels, assets, local status presenters, or replacement workflow affordances. +**Badges / Filters (BADGE-001 / XCUT-001)**: Remove the legacy acknowledged branch through shared findings status seams only. `BadgeCatalog`, `FilterOptionCatalog`, and the existing findings resource or summary builders remain the supported paths; no page-local mapping or one-off status label is allowed. +**Organization**: Tasks are grouped by user story so each slice stays independently verifiable. Recommended delivery order is Phase 1 -> Phase 2 -> `US1` and `US2` in parallel -> `US3` -> final cleanup and validation, because canonical RBAC proof only matters after the workflow and summary surfaces stop carrying acknowledged compatibility. + +**Implementation note**: Several downstream consumers already converged automatically once `Finding::openStatusesForQuery()` and the shared RBAC/filter seams were corrected. Where direct edits in the originally listed consumer files proved unnecessary, completion below reflects the shared-helper cleanup plus targeted validation in the existing downstream tests rather than redundant file-local rewrites. + +## Test Governance Checklist + +- [x] Lane assignment stays `fast-feedback` plus `confidence`, with retained `heavy-governance` guards only in `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, and remains the narrowest sufficient proof for the removed compatibility branch. +- [x] New or changed tests stay in focused `Feature` and `Unit` files only; no browser lane or new heavy-governance family is added. +- [x] Shared helpers, factories, fixtures, and context defaults stay cheap by default; acknowledged-specific setup is deleted or localized instead of becoming a new shared default. +- [x] Planned validation commands stay limited to the targeted Sail test commands captured in `specs/254-remove-acknowledged-compat/plan.md` and the final validation phase below. +- [x] The declared surface test profile stays `standard-native-filament` plus `global-context-shell`; no additional surface exception is introduced. +- [x] Any material suite-footprint or residue follow-up resolves inside this feature as `document-in-feature` or `reject-or-split`, not as silent scope drift. + +## Phase 1: Setup (Shared Cleanup Anchors) + +**Purpose**: Lock the concrete removal inventory, out-of-scope boundaries, and proving commands before implementation starts. + +- [x] T001 [P] Verify the productive acknowledged inventory across `apps/platform/app/Models/Finding.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, `apps/platform/app/Policies/FindingPolicy.php`, `apps/platform/app/Support/Auth/Capabilities.php`, `apps/platform/app/Services/Auth/RoleCapabilityMap.php`, `apps/platform/app/Support/Filament/FilterOptionCatalog.php`, `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`, and `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` +- [x] T002 [P] Verify the shared summary, alert, and query-consumer inventory across `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php`, `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`, `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`, `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php`, `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, `apps/platform/app/Services/Baselines/BaselineAutoCloseService.php`, and `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php` +- [x] T003 [P] Verify the out-of-scope acknowledgement domains stay untouched across `apps/platform/app/Services/Verification/VerificationCheckAcknowledgementService.php`, `apps/platform/app/Filament/Support/VerificationReportViewer.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/tests/Feature/Verification/VerificationCheckAcknowledgementTest.php`, and `apps/platform/tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php` +- [x] T004 [P] Verify the minimum Sail validation commands and file-scoped coverage expectations in `specs/254-remove-acknowledged-compat/plan.md`, `specs/254-remove-acknowledged-compat/spec.md`, and `specs/254-remove-acknowledged-compat/checklists/requirements.md` + +**Checkpoint**: The cleanup boundaries, shared seams, and proving commands are locked before any runtime file is changed. + +--- + +## Phase 2: Foundational (Blocking Proof Surfaces) + +**Purpose**: Make the intended regression proof and stale compatibility inventory explicit before removing shared semantics. + +**CRITICAL**: No user story work should begin until this phase is complete. + +- [x] T005 [P] Create the core status and workflow proof files `apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php` and `apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php` +- [x] T006 [P] Create the shared-surface and RBAC proof files `apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php` and `apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php`, and prove downstream summary consumers through the existing baseline, workspace health, tenant review, support-diagnostics, and alerts tests that already exercise the shared open-status helper. +- [x] T007 [P] Verify retained guard coverage in `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` so findings acknowledged compatibility is treated as removed repo truth rather than tolerated legacy drift +- [x] T008 [P] Audit stale compatibility fixtures and helpers across `apps/platform/database/factories/FindingFactory.php`, `apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php`, `apps/platform/tests/Feature/Models/FindingResolvedTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Unit/Badges/FindingBadgesTest.php`, `apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php`, and `apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php` + +**Checkpoint**: Proof files, guard expectations, and stale compatibility anchors are explicit and ready for bounded implementation work. + +--- + +## Phase 3: User Story 1 - Use One Canonical Findings Workflow Language (Priority: P1) 🎯 MVP + +**Goal**: Remove acknowledged as a productive findings lifecycle concept so tenant findings list, detail, inbox, badges, and filters all speak in one canonical workflow language. + +**Independent Test**: Open the tenant findings register, detail, and assignee inbox for a tenant with active findings and verify that statuses, filters, badges, helper text, and workflow affordances use canonical findings vocabulary only. + +### Tests for User Story 1 + +- [x] T009 [P] [US1] Add canonical status-contract and lifecycle assertions in `apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php` and `apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php` +- [x] T010 [P] [US1] Add shared filter, badge, and list-surface absence proof in `apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php`, `apps/platform/tests/Unit/Badges/FindingBadgesTest.php`, `apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php`, and `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php` + +### Implementation for User Story 1 + +- [x] T011 [US1] Remove productive acknowledged status constants, canonicalization shims, `acknowledge()` helper behavior, and acknowledged factory state usage from `apps/platform/app/Models/Finding.php` and `apps/platform/database/factories/FindingFactory.php` +- [x] T012 [US1] Collapse workflow transitions and policy checks onto canonical triage semantics in `apps/platform/app/Services/Findings/FindingWorkflowService.php` and `apps/platform/app/Policies/FindingPolicy.php` +- [x] T013 [US1] Remove acknowledged filter options, acknowledged detail metadata copy, and acknowledged-specific visibility branches from `apps/platform/app/Support/Filament/FilterOptionCatalog.php`, `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`, and `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` +- [x] T014 [US1] Rewrite stale workflow helper and compatibility assertions in `apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php`, `apps/platform/tests/Feature/Models/FindingResolvedTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Unit/Badges/FindingBadgesTest.php`, and `apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php` + +**Checkpoint**: User Story 1 is independently functional and no productive findings surface still advertises acknowledged as current workflow truth. + +--- + +## Phase 4: User Story 2 - Keep Summary Counts and Previews Honest (Priority: P1) + +**Goal**: Make every shared summary, preview, alert, and consumer query derive open findings from the same canonical status set used by the findings register. + +**Independent Test**: Compare the tenant findings register with the affected `/admin` summary and inbox surfaces for the same tenant or workspace context and verify that counts, previews, drilldowns, and alerts align with the same canonical open-status set. + +### Tests for User Story 2 + +- [x] T015 [P] [US2] Prove shared summary alignment through the existing downstream tests in `apps/platform/tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php` and `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`, with the governance-inbox stale-operations expectation recorded separately as an unrelated existing operation-age failure. +- [x] T016 [P] [US2] Add consumer regression coverage for overview, baseline, tenant-review, diagnostics, and alert paths in `apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php`, `apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php`, and `apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php` + +### Implementation for User Story 2 + +- [x] T017 [US2] Collapse canonical open-status summary builders via the shared `Finding::openStatusesForQuery()` cleanup and validate the unchanged consumers in `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php`, `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, and `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` +- [x] T018 [US2] Remove acknowledged-specific active-count and review-report branches from `apps/platform/app/Support/Baselines/BaselineCompareStats.php` while validating that `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` and `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` already converged through the shared open-status helper. +- [x] T019 [US2] Collapse alert, generator, and hygiene consumers onto canonical open statuses via the shared helper in `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php`, `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, `apps/platform/app/Services/Baselines/BaselineAutoCloseService.php`, and `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php` +- [x] T020 [US2] Rewrite acknowledged-compatibility expectations in `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, and validate the unaffected downstream consumers in `apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php`, and `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php` + +**Checkpoint**: User Story 2 is independently functional and shared counts, previews, review packs, diagnostics, and alerts all reflect the same canonical findings-open set. + +--- + +## Phase 5: User Story 3 - Keep RBAC Language Canonical (Priority: P2) + +**Goal**: Remove the acknowledge capability alias so role guidance, authorization checks, and disabled findings actions all use the surviving canonical findings permission language only. + +**Independent Test**: Review capability-driven findings surfaces and role expectations after the cleanup and verify that they reference surviving findings capabilities only while preserving current `404` versus `403` semantics. + +### Tests for User Story 3 + +- [x] T021 [P] [US3] Add positive and negative capability-alias removal coverage in `apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php` and `apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php` +- [x] T022 [P] [US3] Update role-matrix and UI-enforced findings permission proof in `apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php`, and validate the surviving UI-enforced surface contract in `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` + +### Implementation for User Story 3 + +- [x] T023 [US3] Remove `tenant_findings.acknowledge` from `apps/platform/app/Support/Auth/Capabilities.php` and `apps/platform/app/Services/Auth/RoleCapabilityMap.php` +- [x] T024 [US3] Collapse acknowledge-specific authorization branches and findings UI enforcement onto surviving capabilities in `apps/platform/app/Policies/FindingPolicy.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, and `apps/platform/app/Filament/Resources/FindingResource.php` +- [x] T025 [US3] Rewrite stale RBAC and capability-alias expectations in `apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php`, and `apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php` + +**Checkpoint**: User Story 3 is independently functional and no supported findings permission path or role expectation still names an acknowledge alias. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Remove remaining stale compatibility residue, keep scope boundaries honest, and run the narrow validation workflow. + +- [x] T026 [P] Remove final acknowledged-compatibility residue from findings-only helper and proof surfaces in `apps/platform/tests/Feature/Models/FindingResolvedTest.php`, `apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Unit/Badges/FindingBadgesTest.php`, and `apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php` after the story-specific rewrites land +- [x] T027 [P] Run a residue search for `STATUS_ACKNOWLEDGED`, `TENANT_FINDINGS_ACKNOWLEDGE`, `legacy acknowledged`, and `acknowledge(` across `apps/platform/app/`, `apps/platform/database/factories/`, and `apps/platform/tests/`, then classify any remaining match as in-scope cleanup, allowed non-finding domain, or `reject-or-split` +- [x] T028 Run formatting for touched PHP files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` after the cleanup across `apps/platform/app/`, `apps/platform/database/factories/`, and `apps/platform/tests/` +- [x] T029 [P] Run the focused workflow, filter, and RBAC Sail command `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php tests/Unit/Findings/FindingStatusSemanticsTest.php tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php` +- [x] T030 [P] Run the focused summary and consumer Sail command using the existing downstream proof files `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php tests/Feature/Baselines/BaselineCompareStatsTest.php tests/Feature/Alerts/SlaDueAlertTest.php`, with the governance-inbox stale-operations expectation recorded as an unrelated existing failure outside the acknowledged-status cleanup. +- [x] T031 [P] Run the retained heavy-governance guards `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, then verify no out-of-scope verification or onboarding acknowledgement file from `T003` changed without an explicit split decision +- [x] T032 [P] Verify FR-254-010 explicitly by confirming `Finding` keeps `workspace_id` plus `tenant_id` as unchanged ownership anchors and that no file under `apps/platform/database/migrations/` changed and no new persisted compatibility artifact, alias table, fallback reader, or migration-backed truth was introduced while implementing this slice; if ownership-anchor or persistence widening appears, stop and split the work instead of absorbing it here + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: Starts immediately and locks the exact removal inventory, boundaries, and proving commands. +- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until proof files, guard expectations, and stale compatibility anchors are explicit. +- **User Story 1 (Phase 3)**: Depends on Foundational and is part of the MVP delivery. +- **User Story 2 (Phase 4)**: Depends on Foundational and can proceed in parallel with User Story 1 because it targets shared summary, alert, and query consumers behind the same canonical status set. +- **User Story 3 (Phase 5)**: Depends on User Story 1 because RBAC language should be validated only after findings workflow surfaces stop advertising acknowledged semantics; it can overlap with late User Story 2 cleanup once capability surfaces are isolated. +- **Polish (Phase 6)**: Depends on all desired user stories being complete so residue checks, formatting, and focused validation run on the final cleanup shape. + +### User Story Dependencies + +- **US1**: No dependencies beyond Foundational. +- **US2**: No dependencies beyond Foundational. +- **US3**: Depends on US1 and should validate alongside completed US2 summary cleanup. + +### Within Each User Story + +- Add or update the story tests first and confirm they fail before cleanup edits are considered complete. +- Remove shared compatibility branches centrally instead of hiding acknowledged semantics on one page or in one helper. +- Do not keep compatibility aliases, fallback readers, data-migration shims, or replacement workflow affordances. +- Keep backfill-runtime-surface removal, creation-time invariants hardening, broader lifecycle redesign, verification acknowledgement cleanup, onboarding acknowledgement cleanup, and support-desk work out of scope. + +### Parallel Opportunities + +- `T001`, `T002`, `T003`, and `T004` can run in parallel during Setup. +- `T005`, `T006`, `T007`, and `T008` can run in parallel during Foundational work. +- `T009` and `T010` can run in parallel for User Story 1 before `T011`, `T012`, `T013`, and `T014`. +- `T015` and `T016` can run in parallel for User Story 2 before `T017`, `T018`, `T019`, and `T020`. +- User Story 1 and User Story 2 can proceed in parallel after Foundational is complete. +- `T021` and `T022` can run in parallel for User Story 3 before `T023`, `T024`, and `T025`. +- `T029`, `T030`, and `T031` can run in parallel during final validation. + +--- + +## Parallel Example: User Story 1 + +```bash +# User Story 1 tests in parallel +T009 apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php + apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php +T010 apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php + apps/platform/tests/Unit/Badges/FindingBadgesTest.php + apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php + apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php + +# User Story 1 implementation after the tests are in place +T011 apps/platform/app/Models/Finding.php + apps/platform/database/factories/FindingFactory.php +T012 apps/platform/app/Services/Findings/FindingWorkflowService.php + apps/platform/app/Policies/FindingPolicy.php +T013 apps/platform/app/Support/Filament/FilterOptionCatalog.php + apps/platform/app/Filament/Resources/FindingResource.php + apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php + apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php +``` + +## Parallel Example: User Story 2 + +```bash +# User Story 2 tests in parallel +T015 apps/platform/tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php + apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php +T016 apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php + apps/platform/tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php + apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php + apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php + apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php + apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php + apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php + +# User Story 2 implementation after the tests are in place +T017 apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php + apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php + apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php + apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php +T018 apps/platform/app/Support/Baselines/BaselineCompareStats.php + apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php + apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php +T019 apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php + apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php + apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php + apps/platform/app/Services/Baselines/BaselineAutoCloseService.php + apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php +``` + +## Parallel Example: User Story 3 + +```bash +# User Story 3 tests in parallel +T021 apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php + apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php +T022 apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php + apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php + apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php + apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php + apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php + +# User Story 3 implementation after the tests are in place +T023 apps/platform/app/Support/Auth/Capabilities.php + apps/platform/app/Services/Auth/RoleCapabilityMap.php +T024 apps/platform/app/Policies/FindingPolicy.php + apps/platform/app/Services/Findings/FindingWorkflowService.php + apps/platform/app/Filament/Resources/FindingResource.php +``` + +--- + +## Implementation Strategy + +### MVP First (User Stories 1 and 2) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Complete Phase 4: User Story 2. +5. Run `T028`, `T029`, and `T030` before widening into capability-alias cleanup. + +### Incremental Delivery + +1. Lock the shared seams, out-of-scope domains, and proving commands. +2. Remove acknowledged from the findings model, workflow, filter, badge, and Filament surfaces. +3. Remove acknowledged from summary builders, review or report helpers, alerts, and other shared query consumers. +4. Remove the stale capability alias and role expectations once findings workflow language is already canonical. +5. Finish with residue searches, formatting, and the focused Sail commands. + +### Parallel Team Strategy + +1. One contributor can own the findings model, workflow, and Filament cleanup (`US1`) while another owns shared summaries, alerts, review helpers, and query consumers (`US2`) after Phase 2. +2. Once the two P1 stories land, a focused pass can remove the capability alias and RBAC wording (`US3`) without reopening summary or workflow decisions. +3. A final pass can remove stale residue, run Pint, and execute the three focused Sail validation commands. + +--- + +## Notes + +- Suggested MVP scope: Phase 1 through Phase 4 only. Canonical workflow cleanup without summary or query-consumer alignment is not sufficient for this feature. +- Explicit non-goals remain: backfill-runtime-surface removal, creation-time invariant hardening, broader lifecycle redesign, verification or onboarding acknowledgement cleanup, new workflow states, migration shims, repair tooling, and support-desk workflows. +- Filament stays on Livewire v4 and no panel/provider or asset strategy changes are needed; `FindingResource` already has a view page, so global-search behavior does not need separate tasking in this slice. +- All tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and concrete file paths. \ No newline at end of file -- 2.45.2 From ab9c36f21e7f0628fc6db7dd22d292f1e94cb7a2 Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 29 Apr 2026 12:37:48 +0000 Subject: [PATCH 29/36] =?UTF-8?q?Automatische=20PR:=20platform-dev=20?= =?UTF-8?q?=E2=86=92=20dev=20(#299)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automatisch erstellt: Merge `platform-dev` into `dev` (via MCP) Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/299 --- .../app/Jobs/CompareBaselineToTenantJob.php | 22 +- .../EntraAdminRolesFindingGenerator.php | 22 +- .../PermissionPostureFindingGenerator.php | 20 +- .../Baselines/BaselineCompareFindingsTest.php | 127 ++++++++ .../EntraAdminRolesFindingGeneratorTest.php | 48 +++ .../PermissionPostureFindingGeneratorTest.php | 39 +++ .../checklists/requirements.md | 48 +++ .../finding-creation-invariants.contract.yaml | 101 ++++++ .../data-model.md | 130 ++++++++ .../plan.md | 295 ++++++++++++++++++ .../quickstart.md | 39 +++ .../research.md | 126 ++++++++ .../spec.md | 280 +++++++++++++++++ .../tasks.md | 242 ++++++++++++++ 14 files changed, 1524 insertions(+), 15 deletions(-) create mode 100644 specs/255-enforce-finding-creation-invariants/checklists/requirements.md create mode 100644 specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml create mode 100644 specs/255-enforce-finding-creation-invariants/data-model.md create mode 100644 specs/255-enforce-finding-creation-invariants/plan.md create mode 100644 specs/255-enforce-finding-creation-invariants/quickstart.md create mode 100644 specs/255-enforce-finding-creation-invariants/research.md create mode 100644 specs/255-enforce-finding-creation-invariants/spec.md create mode 100644 specs/255-enforce-finding-creation-invariants/tasks.md diff --git a/apps/platform/app/Jobs/CompareBaselineToTenantJob.php b/apps/platform/app/Jobs/CompareBaselineToTenantJob.php index 06857447..61e10e57 100644 --- a/apps/platform/app/Jobs/CompareBaselineToTenantJob.php +++ b/apps/platform/app/Jobs/CompareBaselineToTenantJob.php @@ -1871,8 +1871,11 @@ private function upsertFindings( } else { $this->observeFinding( finding: $finding, + tenant: $tenant, observedAt: $observedAt, currentOperationRunId: (int) $this->operationRun->getKey(), + severity: (string) $driftItem['severity'], + slaPolicy: $slaPolicy, ); } @@ -1947,12 +1950,21 @@ private function upsertFindings( ]; } - private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void + private function observeFinding( + Finding $finding, + Tenant $tenant, + CarbonImmutable $observedAt, + int $currentOperationRunId, + string $severity, + FindingSlaPolicy $slaPolicy, + ): void { if ($finding->first_seen_at === null) { $finding->first_seen_at = $observedAt; } + $firstSeenAt = CarbonImmutable::instance($finding->first_seen_at); + if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) { $finding->last_seen_at = $observedAt; } @@ -1964,6 +1976,14 @@ private function observeFinding(Finding $finding, CarbonImmutable $observedAt, i } elseif ($timesSeen < 1) { $finding->times_seen = 1; } + + if ($finding->sla_days === null) { + $finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant); + } + + if ($finding->due_at === null) { + $finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt); + } } /** diff --git a/apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php b/apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php index b41f7558..733e4f68 100644 --- a/apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php +++ b/apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php @@ -163,7 +163,7 @@ private function upsertFinding( ->first(); if ($existing instanceof Finding) { - $this->observeFinding($existing, $observedAt); + $this->observeFinding($existing, $tenant, $observedAt, $severity); $existing->forceFill([ 'severity' => $severity, @@ -253,7 +253,7 @@ private function handleGaAggregate( ->first(); if ($existing instanceof Finding) { - $this->observeFinding($existing, $observedAt); + $this->observeFinding($existing, $tenant, $observedAt, Finding::SEVERITY_HIGH); $existing->forceFill([ 'severity' => Finding::SEVERITY_HIGH, @@ -380,24 +380,32 @@ private function resolveSlaPolicy(): FindingSlaPolicy return $this->slaPolicy ?? app(FindingSlaPolicy::class); } - private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void + private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): void { if ($finding->first_seen_at === null) { $finding->first_seen_at = $observedAt; } + $firstSeenAt = CarbonImmutable::instance($finding->first_seen_at); + $lastSeenAt = $finding->last_seen_at; $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) { $finding->last_seen_at = $observedAt; $finding->times_seen = max(0, $timesSeen) + 1; - - return; + } elseif ($timesSeen < 1) { + $finding->times_seen = 1; } - if ($timesSeen < 1) { - $finding->times_seen = 1; + $slaPolicy = $this->resolveSlaPolicy(); + + if ($finding->sla_days === null) { + $finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant); + } + + if ($finding->due_at === null) { + $finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt); } } diff --git a/apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php b/apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php index 55c87959..2796956f 100644 --- a/apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php +++ b/apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php @@ -140,7 +140,7 @@ private function handleMissingPermission( ->first(); if ($finding instanceof Finding) { - $this->observeFinding($finding, $observedAt); + $this->observeFinding($finding, $tenant, $observedAt, $severity); $finding->forceFill([ 'severity' => $severity, @@ -216,7 +216,7 @@ private function handleErrorPermission( ->first(); if ($existing instanceof Finding) { - $this->observeFinding($existing, $observedAt); + $this->observeFinding($existing, $tenant, $observedAt, $severity); $existing->forceFill([ 'severity' => $severity, @@ -349,24 +349,30 @@ private function resolveObservedAt(array $comparison, ?OperationRun $operationRu return CarbonImmutable::now(); } - private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void + private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): void { if ($finding->first_seen_at === null) { $finding->first_seen_at = $observedAt; } + $firstSeenAt = CarbonImmutable::instance($finding->first_seen_at); + $lastSeenAt = $finding->last_seen_at; $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) { $finding->last_seen_at = $observedAt; $finding->times_seen = max(0, $timesSeen) + 1; - - return; + } elseif ($timesSeen < 1) { + $finding->times_seen = 1; } - if ($timesSeen < 1) { - $finding->times_seen = 1; + if ($finding->sla_days === null) { + $finding->sla_days = $this->slaPolicy->daysForSeverity($severity, $tenant); + } + + if ($finding->due_at === null) { + $finding->due_at = $this->slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt); } } diff --git a/apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php b/apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php index 97865402..f187ae45 100644 --- a/apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php +++ b/apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php @@ -528,6 +528,133 @@ expect((string) data_get($finding->evidence_jsonb, 'current.hash'))->not->toBe($currentHash1); }); +it('repairs missing due-state fields on an existing open drift finding without extending the original due date', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + \Carbon\CarbonImmutable::setTestNow(\Carbon\CarbonImmutable::parse('2026-02-24T10:00:00Z')); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => $tenant->workspace_id, + 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => $tenant->workspace_id, + 'baseline_profile_id' => $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => $snapshot->getKey()]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: ['deviceConfiguration' => 'succeeded'], + ); + + $builder = app(InventoryMetaContract::class); + $hasher = app(DriftHasher::class); + + $baselineContract = $builder->build( + policyType: 'deviceConfiguration', + subjectExternalId: 'policy-x-uuid', + metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'], + ); + + $displayName = 'Policy X'; + $subjectKey = BaselineSubjectKey::fromDisplayName($displayName); + expect($subjectKey)->not->toBeNull(); + $workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => $workspaceSafeExternalId, + 'subject_key' => (string) $subjectKey, + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => $hasher->hashNormalized($baselineContract), + 'meta_jsonb' => ['display_name' => $displayName], + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'workspace_id' => $tenant->workspace_id, + 'external_id' => 'policy-x-uuid', + 'policy_type' => 'deviceConfiguration', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_1'], + 'display_name' => $displayName, + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + $opService = app(OperationRunService::class); + + $run1 = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($run1))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $scopeKey = 'baseline_profile:'.$profile->getKey(); + + $finding = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('source', 'baseline.compare') + ->where('scope_key', $scopeKey) + ->sole(); + + $expectedSlaDays = (int) $finding->sla_days; + $expectedDueAt = $finding->due_at?->toIso8601String(); + + expect($expectedSlaDays)->toBeGreaterThan(0) + ->and($expectedDueAt)->not->toBeNull(); + + $finding->forceFill([ + 'sla_days' => null, + 'due_at' => null, + ])->save(); + + \Carbon\CarbonImmutable::setTestNow(\Carbon\CarbonImmutable::parse('2026-02-24T11:00:00Z')); + + $run2 = $opService->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($run2))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + $opService, + ); + + $finding->refresh(); + + expect($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00') + ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00') + ->and($finding->times_seen)->toBe(2) + ->and($finding->sla_days)->toBe($expectedSlaDays) + ->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt); + + \Carbon\CarbonImmutable::setTestNow(); +}); + it('does not create new finding identities when a new snapshot is captured', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); diff --git a/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php b/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php index 52be5f0e..8d392607 100644 --- a/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php +++ b/apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php @@ -195,6 +195,54 @@ function makeGenerator(): EntraAdminRolesFindingGenerator ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00'); }); +it('repairs missing due-state fields on an existing open finding without extending the original due date', function (): void { + [$user, $tenant] = createMinimalUserWithTenant(); + + $generator = makeGenerator(); + + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z')); + $generator->generate($tenant, buildPayload( + [gaRoleDef()], + [makeEntraAssignment('a1', 'def-ga', 'user-1')], + '2026-02-24T10:00:00Z', + )); + + $finding = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES) + ->firstOrFail(); + + $expectedDueAt = $finding->due_at?->toIso8601String(); + + expect($finding->sla_days)->toBe(3) + ->and($expectedDueAt)->toBe('2026-02-27T10:00:00+00:00'); + + $finding->forceFill([ + 'sla_days' => null, + 'due_at' => null, + ])->save(); + + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T11:00:00Z')); + $result = $generator->generate($tenant, buildPayload( + [gaRoleDef()], + [makeEntraAssignment('a1', 'def-ga', 'user-1')], + '2026-02-24T11:00:00Z', + )); + + $finding->refresh(); + + expect($result->created)->toBe(0) + ->and($result->unchanged)->toBe(1) + ->and($finding->status)->toBe(Finding::STATUS_NEW) + ->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00') + ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00') + ->and($finding->times_seen)->toBe(2) + ->and($finding->sla_days)->toBe(3) + ->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt); + + CarbonImmutable::setTestNow(); +}); + it('auto-resolves when assignment is removed', function (): void { [$user, $tenant] = createMinimalUserWithTenant(); diff --git a/apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php b/apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php index 1d071e97..c6cbd5b8 100644 --- a/apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php +++ b/apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php @@ -149,6 +149,45 @@ function errorPermission(string $key, array $features = []): array CarbonImmutable::setTestNow(); }); +it('repairs missing due-state fields on an existing open finding without extending the original due date', function (): void { + [$user, $tenant] = createUserWithTenant(); + $generator = app(PermissionPostureFindingGenerator::class); + + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z')); + $generator->generate($tenant, buildComparison([ + missingPermission('Perm.A', ['policy-sync', 'backup']), + ])); + + $finding = Finding::query()->where('tenant_id', $tenant->getKey())->firstOrFail(); + $expectedDueAt = $finding->due_at?->toIso8601String(); + + expect($finding->sla_days)->toBe(7) + ->and($expectedDueAt)->toBe('2026-03-03T10:00:00+00:00'); + + $finding->forceFill([ + 'sla_days' => null, + 'due_at' => null, + ])->save(); + + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T11:00:00Z')); + $result = $generator->generate($tenant, buildComparison([ + missingPermission('Perm.A', ['policy-sync', 'backup']), + ])); + + $finding->refresh(); + + expect($result->findingsCreated)->toBe(0) + ->and($result->findingsUnchanged)->toBe(1) + ->and($finding->status)->toBe(Finding::STATUS_NEW) + ->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00') + ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00') + ->and($finding->times_seen)->toBe(2) + ->and($finding->sla_days)->toBe(7) + ->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt); + + CarbonImmutable::setTestNow(); +}); + // (5) Re-opens resolved finding when permission revoked again it('re-opens resolved finding when permission is revoked again', function (): void { [$user, $tenant] = createUserWithTenant(); diff --git a/specs/255-enforce-finding-creation-invariants/checklists/requirements.md b/specs/255-enforce-finding-creation-invariants/checklists/requirements.md new file mode 100644 index 00000000..c580066c --- /dev/null +++ b/specs/255-enforce-finding-creation-invariants/checklists/requirements.md @@ -0,0 +1,48 @@ +# Specification Quality Checklist: Enforce Creation-Time Finding Invariants + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-29 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] Repo-specific classes, routes, file paths, and validation commands appear only where they are required to keep the three active writer families and proof obligations unambiguous +- [x] Focused on user value and business needs +- [x] Written for product and review stakeholders, with repo-grounded detail only where the bounded invariant target would otherwise stay ambiguous +- [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 stay outcome-oriented even though the package names concrete writer families and proof files needed to bound the slice +- [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 unbounded implementation plan leaks into the specification; repo-specific commands and paths stay limited to selection, dependency, and validation context + +## Test Governance Review + +- [x] Lane fit is explicit: the package uses `fast-feedback` and `confidence`, with the three writer suites as the primary proof and only bounded recurrence, consumer, and trigger-authorization regressions where FR-255-005, FR-255-006, FR-255-009, and FR-255-011 require them. +- [x] No new browser or heavy-governance family is introduced; adjacent proof remains inside existing feature suites only. +- [x] Suite-cost outcome stays bounded and reviewable: the package reuses existing writer, recurrence, consumer, and auth suites without adding a new default-heavy harness. + +## Review Outcome + +- [x] Review outcome class: `acceptable-special-case` +- [x] Workflow outcome: `keep` +- [x] Review-note location is explicit: guardrail, lane-fit, and bounded-proof notes live in `spec.md`, `plan.md`, `tasks.md`, and this checklist. + +## Notes + +- Repo-surface names, validation commands, and current writer/test anchors are intentionally present because this prep package must distinguish the three active finding writers from already-completed adjacent cleanup specs. +- The spec remains behavior-first: write-time lifecycle readiness, recurrence identity, reopen truth, and unchanged RBAC/tenant isolation are the product outcomes; repo details only keep the package reviewable and bounded. +- No blocking open question remains for safe planning. \ No newline at end of file diff --git a/specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml b/specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml new file mode 100644 index 00000000..321e5534 --- /dev/null +++ b/specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml @@ -0,0 +1,101 @@ +version: 1 +kind: finding-creation-invariants + +scope: + goal: enforce lifecycle-ready finding creation and recurrence or reopen semantics across the active finding writers only + non_goals: + - repair tooling or backfill runtime surfaces + - new workflow states or new findings lifecycle families + - customer-facing workflow expansion + - compare refresh work + - external support handoff + - broader findings redesign + - silent database-constraint rollout + stop_conditions: + - another shipped finding writer is discovered outside the three confirmed paths + - application-level write enforcement proves insufficient without a migration or DB constraint + - the only available implementation shape is a new generic invariant framework + +active_writer_families: + baseline_compare: + owner_files: + - apps/platform/app/Jobs/CompareBaselineToTenantJob.php + identity: + canonical_key: recurrence_key + fingerprint_contract: fingerprint equals recurrence_key + observation_boundary: + duplicate_guard: current_operation_run_id prevents double counting the same compare run + entra_admin_roles: + owner_files: + - apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php + identity: + canonical_key: existing role-assignment or aggregate fingerprint + observation_boundary: + duplicate_guard: later observedAt advances seen history + permission_posture: + owner_files: + - apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php + identity: + canonical_key: existing permission or error fingerprint + observation_boundary: + duplicate_guard: later observedAt advances seen history + +shared_lifecycle_contract: + model: + owner_file: apps/platform/app/Models/Finding.php + invariants: + - workspace_id and tenant_id remain required ownership anchors + - no new status or reason-code family is introduced + reopen_service: + owner_file: apps/platform/app/Services/Findings/FindingWorkflowService.php + requirement: + - terminal findings reopen only through reopenBySystem + - reopened_at is set + - resolved and closed markers clear according to current service behavior + - sla_days and due_at are recalculated from reopenedAt + - existing audit and alert side effects are preserved + +lifecycle_invariants: + create: + required_fields: + - status is new + - first_seen_at equals observedAt + - last_seen_at equals observedAt + - times_seen equals 1 + - sla_days is initialized when the current severity policy returns a value + - due_at is initialized when the current severity policy requires due-state truth + contextual_fields: + - current_operation_run_id remains populated where the current writer already sets it + refresh_existing: + required_behavior: + - the same canonical finding identity is reused + - missing first_seen_at, last_seen_at, and times_seen are repaired inline + - missing sla_days or due_at covered by this slice are repaired inline without a second-pass repair tool + - already-valid lifecycle fields are not reset unnecessarily + reopen: + required_behavior: + - the same canonical finding identity is reopened, not duplicated + - resolved_at and resolved_reason clear on reopen + - first_seen_at is retained + - last_seen_at and times_seen advance according to the family observation rule + +downstream_regression_consumers: + findings_surfaces: + owner_files: + - apps/platform/app/Filament/Resources/FindingResource.php + - apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php + - apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php + - apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php + expectation: + - no design change is required; these surfaces should continue to read truthful due_at and reopened_at data from the same Finding records + +validation_expectations: + required_feature_proof: + - baseline compare proves create readiness, same-run retry protection, reopened reuse, and inline repair of incomplete lifecycle fields + - Entra admin roles proves create readiness, repeated observation, reopened reuse, and inline repair of incomplete lifecycle fields + - permission posture proves create readiness, repeated observation, reopened reuse, and inline repair of incomplete lifecycle fields + excluded_lanes: + - browser + - heavy-governance + migration_posture: + - no new migration or schema artifact is allowed in this slice \ No newline at end of file diff --git a/specs/255-enforce-finding-creation-invariants/data-model.md b/specs/255-enforce-finding-creation-invariants/data-model.md new file mode 100644 index 00000000..005e0ad2 --- /dev/null +++ b/specs/255-enforce-finding-creation-invariants/data-model.md @@ -0,0 +1,130 @@ +# Data Model — Enforce Creation-Time Finding Invariants + +**Spec**: [spec.md](spec.md) + +This feature introduces no new persisted truth. The data-model impact is to make the existing `Finding` lifecycle contract explicit at create, refresh, and reopen time across the three active writer families. + +## Existing Canonical Entities Reused + +### Finding (`findings`) + +**Purpose**: Tenant-owned findings workflow truth. + +**Key fields already in use**: +- `id` +- `workspace_id` +- `tenant_id` +- `finding_type` +- `source` +- `scope_key` +- `fingerprint` +- `recurrence_key` +- `severity` +- `status` +- `first_seen_at` +- `last_seen_at` +- `times_seen` +- `sla_days` +- `due_at` +- `reopened_at` +- `resolved_at` +- `resolved_reason` +- `closed_at` +- `closed_reason` +- `current_operation_run_id` +- `baseline_operation_run_id` + +**Feature use**: +- Remains the single persisted source of truth for active findings lifecycle state. +- Continues to require both `workspace_id` and `tenant_id` anchors. +- Keeps the current status families unchanged. +- Carries the lifecycle-ready fields that this feature hardens at write time. + +### OperationRun (`operation_runs`) + +**Purpose**: Existing execution context for baseline compare and other operational flows. + +**Feature use**: +- Remains contextual only. +- `current_operation_run_id` continues to identify the current writer run where the family already sets it. +- No new operation type or new run-tracking artifact is introduced. + +### StoredReport (`stored_reports`) + +**Purpose**: Existing stored reporting artifact for permission posture output. + +**Feature use**: +- Unchanged. +- Mentioned only because permission posture finding generation already correlates lifecycle-ready findings with an existing report artifact. + +## Derived Non-Persisted Contracts + +### LifecycleReadyFinding (derived contract) + +**Definition**: A `Finding` record that is immediately usable by the existing workflow the moment the active writer persists or refreshes it. + +**Required fields**: +- active canonical status on first create (`new`) +- `first_seen_at` +- `last_seen_at` +- `times_seen >= 1` +- `sla_days` when the current severity policy returns a value +- `due_at` when the current severity policy requires due-date truth +- existing run correlation fields preserved where the writer already populates them + +**Removal rule**: +- no later repair surface may be required for these fields on active writers + +### RecurrenceIdentity (derived contract) + +**Definition**: The family-owned identity that decides whether a repeated observation refreshes one canonical finding or incorrectly creates a duplicate. + +**Family-specific variants**: +- baseline compare: `recurrence_key` and `fingerprint` derived from tenant, baseline profile, policy type, subject key, and change type +- Entra admin roles: existing role-assignment and aggregate fingerprints +- permission posture: existing permission and error fingerprints + +**Guarantee**: +- repeated observation of the same canonical issue reuses one finding identity + +### ObservationBoundary (derived contract) + +**Definition**: The family-specific rule that decides whether `times_seen` should advance. + +**Family-specific variants**: +- baseline compare: same `current_operation_run_id` must not increment `times_seen` twice for the same observation +- Entra admin roles: later `observedAt` advances seen history +- permission posture: later `observedAt` advances seen history + +**Guarantee**: +- retries and repeated processing do not double count the same observation + +## State Transitions Reused + +### Create + +- missing canonical finding identity -> create one `Finding` +- resulting state remains `new` +- lifecycle-ready fields are populated in the same write path + +### Refresh Existing Open Finding + +- existing open finding remains in its current active workflow state +- evidence or severity may refresh according to the writer family +- missing lifecycle-ready fields covered by this feature are repaired inline +- valid existing lifecycle fields should not be needlessly reset + +### Reopen Existing Terminal Finding + +- existing terminal finding transitions through `FindingWorkflowService::reopenBySystem()` +- resulting state becomes `reopened` +- `resolved_*` and `closed_*` markers clear according to the current service behavior +- SLA and due-state truth are recalculated from the later re-observation moment + +## Invariant Rules + +- No new persisted entity, table, or compatibility artifact may be introduced. +- No new workflow status, reopen reason family, or lifecycle label may be introduced. +- Active writers must repair incomplete lifecycle-ready fields inline rather than relying on CLI repair commands, tenant maintenance actions, or deploy-time hooks. +- Due-state repair should fill missing truth or refresh terminal-to-reopened truth only; it must not silently redesign current due-date semantics for already-healthy open findings. +- A later database constraint is a separate follow-up candidate only if application-level write-path enforcement proves insufficient. \ No newline at end of file diff --git a/specs/255-enforce-finding-creation-invariants/plan.md b/specs/255-enforce-finding-creation-invariants/plan.md new file mode 100644 index 00000000..a90835e1 --- /dev/null +++ b/specs/255-enforce-finding-creation-invariants/plan.md @@ -0,0 +1,295 @@ +# Implementation Plan: Enforce Creation-Time Finding Invariants + +**Branch**: `255-enforce-finding-creation-invariants` | **Date**: 2026-04-29 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/spec.md` + +**Note**: This plan is prep-only. It updates only spec-package artifacts for implementation readiness and does not change application code, runtime behavior, migrations, assets, or repo files outside this spec directory. + +## Summary + +- Make lifecycle-ready finding creation and recurrence semantics explicit across the only three active finding writers currently persisting `Finding` records: baseline compare drift, Entra admin roles, and permission posture. +- Keep the slice narrow and repo-grounded: reuse existing `Finding` fields, existing recurrence identities, existing `FindingWorkflowService::reopenBySystem()`, and existing `FindingSlaPolicy` behavior; do not add repair tooling, workflow states, migrations, or a broader findings framework. +- Tighten validation where repo proof is already strongest: extend the three focused feature suites so they explicitly cover new creation, repeated observation, resolved-to-reopened behavior, and inline repair of incomplete lifecycle fields encountered on active write paths. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `FindingWorkflowService`, `FindingSlaPolicy`, baseline compare job, Entra admin roles finding generator, and permission posture finding generator +**Storage**: PostgreSQL existing `findings`, `operation_runs`, `stored_reports`, and `audit_logs` only; no new persistence or migration is planned +**Testing**: Pest feature tests in the existing generator and compare suites +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Sail-backed Laravel web application with tenant `/admin/t/{tenant}` findings surfaces and existing background jobs or services that already generate findings +**Project Type**: web +**Performance Goals**: lifecycle invariants must be satisfied in the same write path that creates or refreshes the finding; no second-pass repair job, no extra operator step, and no widened query surface should be required +**Constraints**: LEAN-001 replacement over compatibility shims; no new persistence; no new workflow states; no compare refresh or repair-tooling scope; preserve existing `404` vs `403` behavior; no new Filament assets, panel work, or provider registration changes +**Scale/Scope**: 3 active finding writer families, 1 shared workflow service, 1 shared SLA policy, 1 existing `Finding` model, and 3 established feature-test families plus downstream findings surfaces as regression consumers only + +## Likely Affected Repo Surfaces + +- Active write paths and their local recurrence or observation logic: + - `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` + - `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` + - `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` +- Shared lifecycle and due-date seams already reused by those paths: + - `apps/platform/app/Services/Findings/FindingWorkflowService.php` + - `apps/platform/app/Services/Findings/FindingSlaPolicy.php` + - `apps/platform/app/Models/Finding.php` +- Downstream operator-facing regression consumers that should not need design changes but do rely on `due_at`, `reopened_at`, and canonical open-status truth: + - `apps/platform/app/Filament/Resources/FindingResource.php` + - `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php` + - `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` + - `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` +- Current focused proof surfaces that already cover part of the invariant and should remain the primary validation entry points: + - `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php` + - `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` + - `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` + +## Domain / Model Fit + +- `Finding` remains the single tenant-owned source of truth with required `workspace_id` and `tenant_id` anchors. No new entity, table, compatibility projection, or lifecycle wrapper is introduced. +- The slice does not change the canonical findings status set. `new`, `triaged`, `in_progress`, and `reopened` remain the active statuses; `resolved`, `closed`, and `risk_accepted` remain terminal statuses. +- Lifecycle-ready creation in this feature means that the first persisted or inline-repaired record is already safe for existing downstream workflow use: canonical active status, `first_seen_at`, `last_seen_at`, `times_seen >= 1`, and existing SLA or `due_at` truth when the current severity policy requires them. +- Recurrence identity stays family-owned and explicit rather than being normalized into a new shared engine: + - baseline compare uses `recurrence_key` plus `fingerprint`, with `current_operation_run_id` preventing double counting for the same compare run + - Entra admin roles uses its existing role-assignment and aggregate fingerprints + - permission posture uses its existing missing-permission and error fingerprints +- `OperationRun` and `StoredReport` remain contextual references only where current writers already use them. This slice does not introduce a new audit artifact or independent lifecycle store. + +## UI / Filament & Livewire Fit + +- No operator-facing surface change is planned. Existing findings resource, inbox, and intake surfaces are regression consumers of better write-time truth, not redesign targets. +- Filament remains v5 on Livewire v4.0+; no Livewire v3 behavior or API is in scope. +- `FindingResource` already has a view page, so the hard global-search rule remains satisfied without new work. No new globally searchable resource is added. +- No destructive action is introduced or changed. Any touched findings action surface must keep current server-side authorization and existing `->requiresConfirmation()` behavior where destructive-like actions already exist. +- No panel/provider work is planned. If provider registration ever became relevant later, Laravel 12 and Filament v5 still require panel providers under `apps/platform/bootstrap/providers.php`, not `bootstrap/app.php`. +- No asset change is planned. Deployment keeps the existing `cd apps/platform && php artisan filament:assets` expectation unchanged only for already-registered assets. + +## RBAC / Policy Fit + +- This slice should not add a new capability, new role mapping, or new policy branch. User-triggered actions that lead to in-scope finding writes keep their current authorization semantics. +- Tenant membership and workspace membership remain the isolation boundary: non-members stay `404`, in-scope members missing the current capability stay `403`, and no new write bypass is introduced for background or queued generation. +- If implementation appears to require a new capability or policy relaxation just to enforce lifecycle invariants, that is a stop condition and should be split rather than absorbed. + +## Audit / Logging Fit + +- `FindingWorkflowService::reopenBySystem()` remains the authoritative reopen path because it already owns reopened state mutation, audit context, and alert notification dispatch. +- No new `AuditActionId`, no new operation type, and no new completion notification path should be introduced. +- The feature should preserve existing `current_operation_run_id` and `StoredReport` correlation meaning where current writers already set them. Creation-time hardening must not create a second audit or run-tracking dialect. + +## Data / Migration / Constraint Fit + +- No migration, no historical data backfill, no deploy hook, and no repair command are planned. +- Under LEAN-001, stale local data or incomplete fixtures should be handled by fixture replacement or inline repair on active write paths, not by compatibility shims. +- A database-level constraint discussion is allowed only as an explicit follow-up or stop condition if planning or implementation proves that application-level write-path enforcement cannot satisfy the invariant safely. It must not be silently folded into this slice. +- If due-date initialization for already-open findings would require recomputing correct existing data instead of filling missing lifecycle fields only, stop and split rather than broadening this feature into a data repair rollout. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: no operator-facing surface change +- **Native vs custom classification summary**: N/A - existing native Filament findings surfaces remain regression consumers only +- **Shared-family relevance**: none; no new notification, header action, dashboard, or evidence viewer family is added +- **State layers in scope**: none +- **Audience modes in scope**: N/A +- **Decision/diagnostic/raw hierarchy plan**: N/A +- **Raw/support gating plan**: N/A +- **One-primary-action / duplicate-truth control**: existing findings workflow actions remain unchanged; tighter write-time truth prevents partial lifecycle data from competing with the existing canonical action flow +- **Handling modes by drift class or surface**: N/A +- **Repository-signal treatment**: review-mandatory for downstream regression only +- **Special surface test profiles**: standard-native-filament regression only +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: no +- **Systems touched**: N/A for shared operator interaction families; domain reuse stays within existing findings lifecycle services only +- **Shared abstractions reused**: existing `FindingWorkflowService` and `FindingSlaPolicy` only +- **New abstraction introduced? why?**: none by default; if a shared write-time normalizer is later proposed, it must be a narrow findings-domain replacement for duplicated inline repair across all three concrete writers, not a new registry or framework +- **Why the existing abstraction was sufficient or insufficient**: `reopenBySystem()` is already sufficient for terminal-to-reopened transitions. The current planning gap is open-record lifecycle repair, which is still duplicated and partially covered across the three writers. +- **Bounded deviation / spread control**: none; keep any repair logic either local to each writer or in one bounded findings-domain helper only if it replaces real duplication immediately + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no +- **Central contract reused**: N/A +- **Delegated UX behaviors**: N/A +- **Surface-owned behavior kept local**: existing baseline compare and other generation flows keep their current start and completion UX unchanged +- **Queued DB-notification policy**: N/A +- **Terminal notification path**: N/A +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: N/A +- **Platform-core seams**: existing tenant-owned findings truth only +- **Neutral platform terms / contracts preserved**: existing `Finding` lifecycle and tenant/workspace ownership vocabulary remain unchanged +- **Retained provider-specific semantics and why**: provider-specific recurrence evidence stays inside the existing writer families that already own it +- **Bounded extraction or follow-up path**: N/A + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- LEAN-001: PASS - the slice is explicitly app-code hardening only; no compatibility shim, legacy alias, fallback reader, or migration path is planned. +- TEST-GOV-001: PASS - proof stays in the narrowest existing feature suites, with no browser lane and no new heavy-governance family. +- RBAC-UX: PASS - no new capability or policy branch is introduced; non-members remain `404`, members lacking the current capability remain `403`, and system generation stays tenant-scoped. +- PERSIST-001: PASS - no new persisted truth, table, artifact, or projection is introduced. +- STATE-001: PASS - no new state, reason-code family, or lifecycle branch is added; current findings states remain authoritative. +- PROP-001 / ABSTR-001: PASS - the narrowest plan is to align the three concrete write paths and reuse the existing reopen service. Any helper beyond that is a stop-and-justify decision, not a default. +- XCUT-001 / UI-SEM-001: PASS - no new operator interaction family or presentation framework is introduced. +- Filament v5 / Livewire v4 compliance: PASS - existing findings surfaces stay on native Filament v5 with Livewire v4.0+; no legacy API mixing is planned. +- Global-search hard rule: PASS - `FindingResource` already has a view page, and no new searchable resource is added. +- Panel/provider registration: PASS - no panel/provider work is planned; if needed later, Filament v5 on Laravel 12 still uses `apps/platform/bootstrap/providers.php`. +- Destructive confirmation standard: PASS - no new destructive action is added; existing destructive-like actions remain outside this slice. +- Asset strategy: PASS - no new panel or shared asset registration is planned; existing deploy behavior for `filament:assets` remains unchanged. +- Auditability and tenant isolation: PASS - reopen semantics remain on the current audited service path, and every in-scope write remains bound to tenant and workspace context. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Feature for writer-level creation-time lifecycle readiness, shared recurrence/workflow-service behavior, and narrow downstream consumer plus trigger-authorization continuity checks; no new unit, browser, or heavy-governance family is planned +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: the feature risk lives in domain write behavior already exercised through the existing compare and generator suites, but FR-255-005, FR-255-006, FR-255-009, and FR-255-011 also require bounded proof of shared recurrence/workflow behavior and unchanged consumer/auth continuity. Focused feature coverage is still sufficient because the adjacent checks stay limited to existing findings and trigger-authorization suites. +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` +- **Fixture / helper / factory / seed / context cost risks**: low to moderate but bounded; reuse existing tenant, operation-run, snapshot, generator, and trigger-surface fixtures. Avoid a new umbrella findings harness unless repeated setup clearly becomes the bottleneck. +- **Expensive defaults or shared helper growth introduced?**: no; the plan explicitly avoids a new generic invariant framework or new default-heavy helper layer. +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: standard-native relief; no browser smoke is required because no operator-facing interaction changes are planned +- **Closing validation and reviewer handoff**: rerun the three writer suites plus the bounded recurrence/workflow and consumer/auth suites, confirm each family now proves missing-field inline repair in addition to existing create/idempotence/reopen behavior, and verify that no migration, no policy branch, and no new UI action was introduced while hardening write paths. +- **Budget / baseline / trend follow-up**: none expected beyond routine feature-test maintenance +- **Review-stop questions**: did implementation widen into a repair tool, migration, DB constraint rollout, or generic invariant framework; did it silently reset already-valid due dates; did it leave one writer family with only partial invariant proof +- **Escalation path**: reject-or-split +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: routine lifecycle-hardening proof belongs in this feature unless a database-level constraint or a broader findings lifecycle redesign is proven necessary later + +## Rollout & Risk Controls + +- Rollout is code-only and bounded. No migration, queue worker sequencing, asset build, or provider registration step is expected. +- Recommended implementation order is: + 1. confirm the shared invariant vocabulary and stop conditions against the three active writers only + 2. harden baseline compare first because it already carries the strictest observation-boundary rule through `current_operation_run_id` + 3. align permission posture and Entra admin roles creation and refresh logic around the same lifecycle-ready contract while preserving their family-specific recurrence rules + 4. extract a shared normalizer only if the concrete code shows immediate duplication across all three paths and the helper replaces duplication instead of adding a new abstraction layer + 5. extend focused regression tests and verify downstream findings surfaces do not require design changes +- Stop conditions for task execution: + - another shipped finding writer is discovered outside the three confirmed paths + - the invariant cannot be enforced safely without a migration or DB constraint + - the only available code shape is a new generic registry, strategy system, or lifecycle framework + - user-facing findings workflow affordances would need to change to compensate for missing write-time truth + +## Project Structure + +### Documentation (this feature) + +```text +specs/255-enforce-finding-creation-invariants/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── checklists/ +│ └── requirements.md +├── contracts/ +│ └── finding-creation-invariants.contract.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Jobs/ +│ │ └── CompareBaselineToTenantJob.php +│ ├── Models/ +│ │ └── Finding.php +│ ├── Services/ +│ │ ├── EntraAdminRoles/ +│ │ │ └── EntraAdminRolesFindingGenerator.php +│ │ ├── Findings/ +│ │ │ ├── FindingSlaPolicy.php +│ │ │ └── FindingWorkflowService.php +│ │ └── PermissionPosture/ +│ │ └── PermissionPostureFindingGenerator.php +│ └── Filament/ +│ ├── Pages/Findings/ +│ │ ├── FindingsIntakeQueue.php +│ │ └── MyFindingsInbox.php +│ └── Resources/ +│ ├── FindingResource.php +│ └── FindingResource/ +│ └── Pages/ListFindings.php +└── tests/ + └── Feature/ + ├── Baselines/BaselineCompareFindingsTest.php + ├── EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php + ├── Findings/FindingRecurrenceTest.php + ├── Findings/FindingAutomationWorkflowTest.php + ├── Findings/FindingWorkflowServiceTest.php + ├── Findings/MyWorkInboxTest.php + ├── Findings/FindingsIntakeQueueTest.php + ├── Rbac/BaselineCompareMatrixAuthorizationTest.php + ├── EntraAdminRoles/AdminRolesSummaryWidgetTest.php + └── PermissionPosture/PermissionPostureFindingGeneratorTest.php +``` + +**Structure Decision**: Laravel monolith. The implementation should stay inside the existing finding writer services and job, the shared findings lifecycle service and model, and the current focused feature suites rather than creating a new namespace or framework. + +## Complexity Tracking + +No constitution violation is expected. If implementation later proposes a new persistence rule, a new lifecycle framework, or a broad helper layer that serves only speculative future writers, stop and split rather than justifying it inside this slice. + +## Proportionality Review + +N/A - this feature introduces no new enum, presenter, persisted entity, interface, registry, or taxonomy. Any narrow helper extracted during implementation must replace existing duplicated write-time lifecycle normalization immediately across the three confirmed writers or it should not be introduced. + +## Phase 0 — Research (output: `research.md`) + +See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/research.md` + +Goals: +- confirm that the three already-named write paths are still the full active finding-writer inventory in app code +- confirm where current code already repairs lifecycle fields inline and where `sla_days` or `due_at` normalization is still only implied on create or reopen +- document the narrowest shared seam decision: keep repair logic local per writer unless one bounded findings-domain helper clearly replaces real duplication across all three cases +- record the explicit stop condition for any database-level constraint or migration-based enforcement proposal + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +See: +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/data-model.md` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml` +- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/quickstart.md` + +Design focus: +- capture one lifecycle-ready finding contract that all three active writers must satisfy without introducing a new persistence or workflow layer +- keep recurrence identity family-owned while making the create, refresh, and reopen guarantees explicit in one planning contract +- keep downstream Filament findings surfaces, inboxes, and intake queues as regression consumers only; no UI redesign is part of this slice +- document the no-migration, no-constraint-by-default posture and the explicit stop condition for any future constraint follow-up + +## Phase 1 — Agent Context Update + +- Deferred in this prep-only pass because the user explicitly limited edits to this spec directory. +- If maintainers later want full Spec Kit propagation outside the spec package, run: + - `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot` + +## Phase 2 — Implementation Outline (tasks created later in `/speckit.tasks`) + +- keep the feature bounded to the three confirmed writer paths and the shared reopen service +- align creation-time lifecycle initialization and open-record inline repair in `CompareBaselineToTenantJob`, `EntraAdminRolesFindingGenerator`, and `PermissionPostureFindingGenerator` +- preserve family-specific recurrence and observation-boundary behavior while making it explicit in code and tests +- preserve `FindingWorkflowService::reopenBySystem()` as the only reopened-state mutation path +- extend the three focused feature suites so each family proves creation readiness, repeated observation, resolved-to-reopened behavior, and inline repair of incomplete lifecycle fields encountered on active paths +- verify that no migration, no new capability, no new workflow state, no repair surface, and no operator-facing workflow expansion slipped into the implementation slice + +## Constitution Check (Post-Design) + +Re-check target: PASS. The post-design shape remains prep-only, introduces no new persistence or state family, keeps Filament on Livewire v4.0+, leaves provider registration unchanged in `apps/platform/bootstrap/providers.php`, keeps global search unchanged through the existing `FindingResource` view page, leaves destructive actions untouched, and keeps the proving burden inside the three existing focused feature suites unless a bounded stop condition forces a split. +- **Ownership cost created**: focused ongoing maintenance in the three writer suites plus bounded shared recurrence/workflow and trigger-authorization regressions; no migration, framework, or new persistence cost is added. +- **Alternative intentionally rejected**: a generic invariant framework, a new repair or backfill path, and any DB-constraint rollout were rejected because the repo currently has three concrete writers and current-release truth only requires tightening those exact paths. +- **Release truth**: current-release truth. This package hardens already-shipped finding writers rather than preparing speculative future families. diff --git a/specs/255-enforce-finding-creation-invariants/quickstart.md b/specs/255-enforce-finding-creation-invariants/quickstart.md new file mode 100644 index 00000000..6a91f56b --- /dev/null +++ b/specs/255-enforce-finding-creation-invariants/quickstart.md @@ -0,0 +1,39 @@ +# Quickstart — Enforce Creation-Time Finding Invariants + +## Prereqs + +- Docker running +- Laravel Sail dependencies installed +- Existing compare and generator feature fixtures available +- Existing tenant/workspace helpers available for targeted findings tests + +## Run locally after implementation + +- Start containers: `cd apps/platform && ./vendor/bin/sail up -d` +- Use the normal repo baseline before running tests: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan migrate --no-interaction` +- Run the focused validation suites for this slice: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` +- Format any implementation changes: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +The two additional commands are the only bounded adjacent proof beyond the three writer suites. They cover shared recurrence/workflow semantics plus unchanged downstream consumer and trigger-authorization contracts. + +## Manual smoke after implementation + +1. Trigger one baseline compare drift finding and confirm the newly created record appears immediately usable on `/admin/t/{tenant}/findings`, including due-state and seen-history cues where current UI already renders them. +2. Trigger one permission posture and one Entra admin roles finding and confirm the first persisted record has the expected lifecycle-ready fields without any maintenance action. +3. Resolve an in-scope finding, re-observe the same issue, and confirm the same finding identity reopens with refreshed due or SLA truth and existing history retained. +4. Re-run the same baseline compare operation identity and confirm `times_seen` does not double count on retry. +5. Review the diff and confirm no file under `apps/platform/database/migrations/` changed and no new repair surface, capability, or operator-facing workflow branch was introduced. + +## Notes + +- Filament v5 remains on Livewire v4.0+ in this repo; this feature does not add or redesign an operator-facing Filament surface. +- `FindingResource` already has a view page, so there is no new global-search compliance work. +- No new destructive action is planned; existing destructive-like findings actions stay outside this slice and keep their current confirmation and authorization behavior. +- No panel or provider change is planned; `apps/platform/bootstrap/providers.php` remains the relevant provider-registration location for Filament work in Laravel 12. +- No asset change is expected, so there is no additional `filament:assets` deployment work for this slice. +- This prep package intentionally leaves repo-wide agent-context regeneration outside scope so changes stay inside `specs/255-enforce-finding-creation-invariants/` only. \ No newline at end of file diff --git a/specs/255-enforce-finding-creation-invariants/research.md b/specs/255-enforce-finding-creation-invariants/research.md new file mode 100644 index 00000000..c7a0e857 --- /dev/null +++ b/specs/255-enforce-finding-creation-invariants/research.md @@ -0,0 +1,126 @@ +# Research — Enforce Creation-Time Finding Invariants + +**Date**: 2026-04-29 +**Spec**: [spec.md](spec.md) + +This document records the repo-grounded planning decisions for the creation-time findings hardening slice after Specs 253 and 254. All decisions assume the current pre-production LEAN-001 posture. + +## Decision 1 — Scope the feature to the three active finding writers that currently persist `Finding` records + +**Decision**: Treat baseline compare drift, Entra admin roles, and permission posture as the full active writer set for this feature. + +**Rationale**: +- Repo search shows only five direct `Finding` creation sites in app code: one `new Finding` path in `CompareBaselineToTenantJob` and four `Finding::create()` sites split between Entra admin roles and permission posture. +- No other shipped service or job currently persists `Finding` records directly, so widening the slice would be speculative rather than repo-driven. +- This keeps the hardening aligned with the spec's stated bounded scope and avoids inventing a new writer registry. + +**Evidence**: +- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` +- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` +- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` + +**Alternatives considered**: +- Widen to every findings consumer or downstream summary surface. + - Rejected: those surfaces consume findings truth but do not create it. +- Add a speculative "all writers" registry now. + - Rejected: violates ABSTR-001 because three concrete paths are already directly visible. + +## Decision 2 — Enforce lifecycle readiness in the same write path, not through a later repair pass + +**Decision**: Require each in-scope writer to create or refresh lifecycle-ready findings inside the same code path that persists or updates the record. + +**Rationale**: +- Spec 253 removes runtime backfill surfaces and this feature explicitly exists to prevent reintroducing that repair dependency. +- Current code already initializes lifecycle fields on new creates and updates some fields inline on repeated observations; that makes write-path hardening the narrowest correct implementation. +- Downstream findings pages, inboxes, and intake queues already assume findings are ready for immediate use. + +**Evidence**: +- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` +- `apps/platform/app/Filament/Resources/FindingResource.php` +- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` + +**Alternatives considered**: +- Reintroduce a maintenance action or backfill command. + - Rejected: directly conflicts with the cleanup direction from Spec 253. +- Add a deploy-time or queue-time repair hook. + - Rejected: widens scope and hides invariant ownership. + +## Decision 3 — Preserve `FindingWorkflowService::reopenBySystem()` as the only shared reopen path + +**Decision**: Keep terminal-to-reopened mutation on `FindingWorkflowService::reopenBySystem()` and treat open-record lifecycle normalization as the actual planning gap. + +**Rationale**: +- `reopenBySystem()` already validates terminal-status eligibility, recalculates SLA or due state, clears resolved or closed markers, writes audit context, and dispatches the reopened alert notification. +- Bypassing it would create a second reopen dialect and risk inconsistent audit or notification semantics. +- The repo gap is not reopened-state ownership; it is that current open-record repair is still distributed across per-family `observeFinding()` logic and currently emphasizes seen-history more than full lifecycle readiness. + +**Evidence**: +- `apps/platform/app/Services/Findings/FindingWorkflowService.php` +- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` +- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` +- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` + +**Alternatives considered**: +- Reopen findings directly inside each writer. + - Rejected: duplicates side effects and weakens audit consistency. +- Create a new generic lifecycle orchestration framework. + - Rejected: too broad for three known writers. + +## Decision 4 — Keep recurrence identity family-owned and preserve each writer's current double-count boundary + +**Decision**: Keep the existing recurrence identity and observation boundary per family instead of forcing one synthetic cross-domain algorithm. + +**Rationale**: +- Baseline compare already uses `recurrence_key` plus `fingerprint` with `current_operation_run_id` to suppress duplicate `times_seen` increments for the same compare run. +- Entra admin roles and permission posture use later `observedAt` comparisons to advance seen history. +- The operator need is one canonical finding identity per issue family, not one universal recurrence engine. + +**Evidence**: +- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` +- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` +- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` + +**Alternatives considered**: +- Normalize all writers onto a single recurrence service. + - Rejected: would add abstraction without current-release need. +- Count every repeated observation the same way across all writers. + - Rejected: risks breaking baseline retry semantics. + +## Decision 5 — The current proof gap is inline repair of incomplete lifecycle fields on existing findings + +**Decision**: Plan for explicit regression proof that existing open findings with missing lifecycle fields are repaired inline on active paths, especially for `sla_days` and `due_at`. + +**Rationale**: +- Existing tests already prove creation readiness, idempotence, and reopen behavior in all three families. +- Repo code also already repairs `first_seen_at`, `last_seen_at`, and `times_seen` inline when existing findings are re-observed. +- What is not yet clearly owned as one invariant is the repair of incomplete lifecycle fields such as missing due-state data on existing findings encountered through active writers. + +**Evidence**: +- `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php` +- `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` +- `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` + +**Alternatives considered**: +- Rely on current creation and reopen tests only. + - Rejected: leaves FR-255-007 partially implied. +- Add a new browser or broad workflow suite. + - Rejected: too expensive for a write-path invariant gap. + +## Decision 6 — Keep schema and DB constraints out of the slice unless they become an explicit stop condition + +**Decision**: Keep the default plan app-code-only. Any database-level constraint or migration-based enforcement is a bounded follow-up candidate or an explicit stop condition, not part of this feature by default. + +**Rationale**: +- The repo is pre-production and LEAN-001 favors direct replacement over compatibility layers. +- The current code already has the necessary domain seams to harden write-time behavior without changing the schema. +- Folding a constraint into this feature would silently broaden it from write-path hardening into data rollout and compatibility review. + +**Evidence**: +- `.specify/memory/constitution.md` +- `specs/255-enforce-finding-creation-invariants/spec.md` + +**Alternatives considered**: +- Add `NOT NULL` or check constraints now. + - Rejected: outside the smallest bounded slice. +- Keep the option undefined. + - Rejected: the plan must name the stop condition explicitly so task generation stays bounded. \ No newline at end of file diff --git a/specs/255-enforce-finding-creation-invariants/spec.md b/specs/255-enforce-finding-creation-invariants/spec.md new file mode 100644 index 00000000..b36b3c75 --- /dev/null +++ b/specs/255-enforce-finding-creation-invariants/spec.md @@ -0,0 +1,280 @@ +# Feature Specification: Enforce Creation-Time Finding Invariants + +**Feature Branch**: `255-enforce-finding-creation-invariants` +**Created**: 2026-04-29 +**Status**: Draft +**Input**: User description: "Prepare only the spec artifacts for `Enforce Creation-Time Finding Invariants` on the existing 255 branch as the next bounded findings data-integrity slice after Specs 253 and 254." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: Findings that reach operators through active generators already tend to look lifecycle-ready, but that truth is still implied and distributed. If a new or changed generator path omits status, seen timestamps, seen count, or due/SLA initialization, operators can receive findings that look real before they are workflow-ready. +- **Today's failure**: TenantPilot would have to rely on scattered implicit behavior or future repair logic to make a new or recurring finding usable. That weakens trust in due state, recurrence history, and reopen behavior right at the moment an operator is asked to act. +- **User-visible improvement**: Newly created or reopened findings arrive already ready for existing workflow use, with stable identity and lifecycle metadata that operators can trust immediately. +- **Smallest enterprise-capable version**: Make creation-time and recurrence-time finding invariants explicit for the active generator families and their shared reopen semantics, backed by focused regression proof, while reusing existing finding fields and workflow states only. +- **Explicit non-goals**: No backfill runtime surfaces, no acknowledged cleanup, no new customer-facing workflow, no broader findings lifecycle redesign, no new persistence, no new states, no external integration, no owner/assignee mandate, and no schema rollout except a possible future narrow follow-up. +- **Permanent complexity imported**: Low and bounded. The feature should add only explicit invariant coverage and possibly a narrow shared write-time guard if planning proves it necessary; no new table, state family, framework, or operator surface is justified. +- **Why now**: Specs 253 and 254 remove adjacent repair and compatibility debt. The next bounded unspecced findings candidate is to lock in the post-cleanup target state so active generators cannot drift back into repair-tool dependency. +- **Why not local**: Repo truth spans baseline compare, Entra admin roles, permission posture, shared reopen behavior, SLA/due initialization, and recurrence semantics. A local fix in one generator would leave the others as implied behavior and keep the invariant unowned. +- **Approval class**: Core Enterprise +- **Red flags triggered**: Multiple micro-specs for one domain. Defense: this is the final bounded data-integrity hardening slice after surface removal and acknowledged cleanup, and it explicitly avoids bundling broader lifecycle redesign or new infrastructure. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Selection Rationale + +- The source candidate is the active P1 entry `Enforce Creation-Time Finding Invariants` in `docs/product/spec-candidates.md`. +- This slice sits in the Findings Workflow / Data Integrity sequence and follows Spec 253 (`Remove Findings Lifecycle Backfill Runtime Surfaces`) and Spec 254 (`Remove Legacy Acknowledged Finding Status Compatibility`). +- It is the next bounded unspecced candidate in repo order. Customer Review Workspace, Decision-Based Governance Inbox, Commercial Entitlements and Billing-State Maturity, Platform Localization, Remove Findings Lifecycle Backfill Runtime Surfaces, and Remove Legacy Acknowledged Finding Status Compatibility already have specs. +- `External Support Desk / PSA Handoff` remains blocked because the repo still does not name one concrete external desk or PSA target. +- `Cross-Tenant Compare and Promotion v1` already has Spec 043 and is a refresh candidate, not the next unspecced preparation target. +- The smallest viable slice is to prove that active finding generators and reopen/recurrence paths always create or refresh findings in a lifecycle-ready state at write time, without reintroducing repair tooling, redesigning the lifecycle, adding new persistence, adding new workflow states, or widening into external workflow work. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant +- **Primary Routes**: + - No new or changed direct route is the product target. + - Existing tenant findings surfaces are downstream regression consumers only: `/admin/t/{tenant}/findings` and `/admin/t/{tenant}/findings/{record}`. + - In-scope behavior is reached through existing tenant-scoped finding generation paths, including baseline compare completion, Entra admin role finding generation, and permission posture finding generation. +- **Data Ownership**: + - Tenant-owned `Finding` records remain the canonical truth and keep required `workspace_id` plus `tenant_id` anchors. + - Existing `OperationRun` and `StoredReport` references stay contextual only where the current generators already use them; this feature introduces no new persisted entity, mirror table, or compatibility artifact. + - The scope is limited to write-time creation and refresh behavior for existing finding truth. +- **RBAC**: + - Tenant membership remains the isolation boundary for the downstream findings surfaces that consume these records. + - Existing user-triggered paths that lead to in-scope finding creation remain capability-first; non-members stay 404 and members lacking the current capability stay 403. + - Background or system-triggered generation must preserve tenant/workspace isolation and must not create a bypass that can write findings outside the current tenant scope. + +## 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?**: no +- **Interaction class(es)**: N/A - no shared operator interaction family is added or changed +- **Systems touched**: N/A - operator-facing shared interaction patterns stay unchanged +- **Existing pattern(s) to extend**: none +- **Shared contract / presenter / builder / renderer to reuse**: none +- **Why the existing shared path is sufficient or insufficient**: This slice hardens tenant-owned finding writes and shared lifecycle semantics, not notifications, action surfaces, or dashboard presentation. +- **Allowed deviation and why**: none +- **Consistency impact**: downstream findings and review surfaces continue consuming the same finding truth without any new UI branch +- **Review focus**: reviewers should verify that the feature stays in write-time lifecycle hardening and does not smuggle in new operator interaction patterns + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: no +- **Shared OperationRun UX contract/layer reused**: N/A +- **Delegated start/completion UX behaviors**: N/A +- **Local surface-owned behavior that remains**: existing baseline compare and other current generation flows keep their current launch, completion, and link UX; this slice only hardens the finding writes they already produce +- **Queued DB-notification policy**: N/A +- **Terminal notification path**: N/A +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: no +- **Boundary classification**: N/A +- **Seams affected**: N/A +- **Neutral platform terms preserved or introduced**: N/A +- **Provider-specific semantics retained and why**: N/A +- **Why this does not deepen provider coupling accidentally**: The slice hardens existing tenant-owned finding lifecycle truth across already-active generators without introducing a new shared provider seam, taxonomy, or vocabulary. +- **Follow-up path**: none + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +N/A - no operator-facing surface change. Existing findings, review, and summary surfaces are regression consumers of better write-time truth, not redesign targets in this feature. + +## 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?**: no +- **Current operator problem**: Operators should not receive a newly created or reopened finding that still depends on an implicit later repair step before due state, seen history, or canonical workflow use is trustworthy. +- **Existing structure is insufficient because**: the write-time invariant exists only as distributed repo behavior today. It is partially proven in separate tests, but not yet owned as one explicit product hardening slice across the active generator families and their recurrence semantics. +- **Narrowest correct implementation**: make the invariant explicit across the verified active generator families and shared reopen/recurrence behavior, using the existing finding fields, existing workflow states, existing SLA policy, and focused regression proof only. +- **Ownership cost**: a small amount of enduring regression coverage and possibly a narrow shared write-time guard if planning proves it necessary. No new table, state family, or general framework is justified. +- **Alternative intentionally rejected**: reintroducing lifecycle backfill or repair tooling, adding a new invariant framework or persistence layer, or widening into a broader findings lifecycle redesign. +- **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**: Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: the behavioral proof stays centered on the three focused writer suites around baseline compare, Entra admin roles, and permission posture, with only bounded adjacent regression in shared recurrence/workflow-service and downstream consumer/auth continuity tests because FR-255-005, FR-255-006, FR-255-009, and FR-255-011 cross the writer boundaries. +- **New or expanded test families**: none by default; reuse and tighten the three focused writer suites, plus bounded regression in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`, `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`, `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` only where they prove shared recurrence, consumer honesty, or unchanged trigger authorization +- **Fixture / helper cost impact**: low and near-neutral. The default path should reuse existing tenant, finding, and operation helpers instead of adding a broader harness. +- **Heavy-family visibility / justification**: none. No new heavy-governance or browser family is justified for this slice. +- **Special surface test profile**: N/A +- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient because this slice hardens domain truth behind existing workflows rather than adding a new UI surface +- **Reviewer handoff**: reviewers must confirm that the final proof covers new finding creation, repeated observation, resolved-to-reopened transitions, unchanged 404 versus 403 semantics on the existing trigger surfaces, and preserved `current_operation_run_id` meaning without expanding into unrelated workflow or UI coverage +- **Budget / baseline / trend impact**: none expected beyond ordinary focused feature-test upkeep +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` + +## RBAC / Isolation Considerations + +- Tenant-owned findings remain scoped by `workspace_id` and `tenant_id`. The feature must not create or preserve tenantless finding truth. +- Existing user-triggered operations that can lead to the in-scope finding writes keep current capability-first authorization. This slice does not add a new capability or role alias. +- Downstream findings and review surfaces keep current deny-as-not-found versus forbidden behavior: non-members remain 404, in-scope members missing the existing capability remain 403 on triggering actions. +- Explicit 404 versus 403 continuity proof stays bounded to `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php` and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`, because permission posture finding generation is background-triggered rather than launched from a separate tenant UI action. +- System-initiated reopen or refresh behavior stays inside the current tenant/workspace context and must not widen read or write visibility across tenants. + +## Auditability + +- Existing workflow-driven reopen semantics remain authoritative for system reopen behavior. The feature must preserve current audit and workflow meaning instead of introducing a silent side path. +- Existing `current_operation_run_id` correlations stay in place where the current generators already populate them; this slice does not add a second run-correlation path or new audit artifact. +- The hardening must not allow partially initialized findings to look settled or complete on downstream operator surfaces. The audit trail should continue to explain system-created and system-reopened findings through the existing lifecycle paths. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - See Ready Findings Immediately (Priority: P1) + +As a tenant operator, I want a newly detected finding to arrive already ready for the existing findings workflow so I can trust the status, due state, and seen history the first time it appears. + +**Why this priority**: This is the core product-truth outcome. If new findings still depend on implied repair logic, the feature has failed. + +**Independent Test**: Can be fully tested by triggering one new finding in each in-scope generator family and verifying that the first persisted record is already lifecycle-ready before any downstream findings page or review surface consumes it. + +**Acceptance Scenarios**: + +1. **Given** a baseline compare run detects new drift for a tenant, **When** the finding is first written, **Then** it already carries the canonical open status, first seen and last seen timestamps, seen count, and the due or SLA data required by the existing workflow. +2. **Given** an Entra admin roles or permission posture run detects a new issue, **When** the tenant findings register later displays that record, **Then** no backfill, repair action, or second pass is required to make the finding usable in the existing workflow. + +--- + +### User Story 2 - Reopen the Same Finding When the Risk Returns (Priority: P1) + +As a tenant operator, I want a resolved issue that reappears to reopen the same finding with fresh lifecycle truth so I can continue work with the existing history instead of receiving a duplicate or stale record. + +**Why this priority**: Reopen behavior is the critical recurrence path that keeps findings trustworthy after the cleanup sequence in Specs 253 and 254. + +**Independent Test**: Can be fully tested by resolving an in-scope finding, observing the same issue again, and verifying that the existing finding reopens with refreshed lifecycle fields. + +**Acceptance Scenarios**: + +1. **Given** a previously resolved baseline drift finding reappears, **When** the same drift is observed again, **Then** the existing finding reopens, resolved markers clear as needed, and the lifecycle fields required for current workflow use are refreshed at write time. +2. **Given** a previously resolved Entra admin roles or permission posture finding reappears, **When** the generator sees the same active issue again, **Then** the system reopens the same finding identity and refreshes the due or SLA truth according to the current severity policy already in use. + +--- + +### User Story 3 - Keep One Canonical Finding Identity Through Repeated Detection (Priority: P2) + +As a tenant operator, I want repeated detection of the same active issue to strengthen the same finding record instead of creating uncontrolled duplicates or inflating seen counts incorrectly. + +**Why this priority**: Stable recurrence semantics protect operator trust in counts, history, and due attention without widening the feature into broader lifecycle redesign. + +**Independent Test**: Can be fully tested by retrying or repeating the same observation across the in-scope families and verifying one canonical finding identity with bounded seen-count updates. + +**Acceptance Scenarios**: + +1. **Given** the same canonical issue is retried under the same observation identity, **When** the generator processes it again, **Then** the system does not create a duplicate finding and does not double-count the same observation. +2. **Given** the same canonical issue is observed again under a later valid observation, **When** the generator refreshes the existing finding, **Then** the same finding identity remains in place and the seen history advances according to that family's existing recurrence semantics. + +### Edge Cases + +- A retried baseline compare job using the same run identity must not increment `times_seen` twice for the same observation. +- An existing finding encountered on a normal active path may still be missing `first_seen_at`, `last_seen_at`, or `times_seen`; the in-scope write path must repair those fields inline instead of depending on a separate repair surface. +- A resolved finding should reopen only when the new observation is later than the prior resolution boundary; out-of-order or stale observations must not incorrectly reopen it. +- If current SLA policy derives a due date from severity, the reopened or newly created record must be ready for that downstream truth immediately; the feature must not defer due-state initialization to a later process. +- The feature must preserve one canonical finding identity even when evidence payloads or current hashes change between observations. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature changes tenant-owned finding write behavior but does not add Microsoft Graph calls, a new user-facing mutation surface, or a new long-running workflow. It hardens existing write-time semantics in current generator paths, preserves tenant isolation, preserves existing audit meaning plus `current_operation_run_id` correlation on the in-scope write paths, and requires focused regression proof. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The slice must not introduce new persistence, new abstraction, new state family, or new semantic layer. If planning proposes a shared invariant helper, it must prove why the existing distributed write paths cannot safely stay explicit without creating a new unowned drift point. + +**Constitution alignment (XCUT-001):** No new cross-cutting operator interaction family is allowed in this slice. Existing findings, review, and summary surfaces remain unchanged consumers of better write-time truth. + +**Constitution alignment (TEST-GOV-001):** Proof stays in the narrow focused feature tests already closest to the active generator families and their recurrence behavior. The feature must not create a new heavy family or browser dependency. + +**Constitution alignment (OPS-UX / OPS-UX-START-001):** Existing baseline compare and other current generation flows may continue using their current `OperationRun` semantics where already present, but this feature does not add or change operation start UX, queued notification policy, or deep-link behavior. + +**Constitution alignment (RBAC-UX):** Existing triggering authorization stays capability-first and unchanged. The feature must not add a hidden bypass or new capability branch to create or reopen findings. + +**Constitution alignment (OPSURF-001 / DECIDE-AUD-001):** Existing operator surfaces must never depend on partially initialized finding truth. The hardening exists so downstream decision surfaces continue to show calm, honest workflow data without false readiness. + +### Functional Requirements + +- **FR-255-001**: The system MUST ensure each in-scope active finding generator family writes a newly created finding in a lifecycle-ready state within the same write path that first persists the record. +- **FR-255-002**: The in-scope active generator families for this feature are baseline compare drift, Entra admin roles, and permission posture. The invariant MUST be explicit across all three families, not only one local path. +- **FR-255-003**: A newly created in-scope finding MUST carry the canonical initial workflow status plus the lifecycle fields needed by existing downstream workflow surfaces, including first seen and last seen timestamps, a valid seen count, and existing SLA or due-date truth when the current severity policy already requires them. +- **FR-255-004**: Repeated observation of the same active condition MUST reuse one canonical finding identity through the existing recurrence key or fingerprint semantics and MUST refresh the existing record instead of creating uncontrolled canonical duplicates. +- **FR-255-005**: A retry or repeated processing of the same observation identity MUST NOT double-count the same observation. Each in-scope generator family may keep its current observation semantics, but the feature MUST make those semantics explicit and regression-protected. +- **FR-255-006**: When a previously resolved in-scope finding reappears, the system MUST reopen the existing finding through the current workflow path and MUST clear or refresh the lifecycle data required for immediate downstream workflow use. +- **FR-255-007**: If an in-scope active path encounters an existing finding with missing lifecycle fields covered by this slice, the normal write path MUST repair those fields inline instead of depending on backfill jobs, tenant repair actions, CLI repair commands, or deploy-time hooks. +- **FR-255-008**: The feature MUST preserve current tenant/workspace isolation by keeping every in-scope finding write anchored to the current tenant and workspace and by not widening visibility or write scope across tenants. +- **FR-255-009**: The feature MUST preserve capability-first RBAC and existing 404 versus 403 semantics on the current user-triggered entry points that lead to in-scope finding creation or refresh, specifically the baseline compare matrix and admin-roles scan surfaces. +- **FR-255-010**: The feature MUST preserve existing finding workflow states, downstream review surfaces, and operator affordances. It MUST NOT add new workflow states, reintroduce repair tooling, re-open acknowledged-status cleanup, require owner or assignee fields, or add external support or PSA workflow scope. +- **FR-255-011**: The feature MUST keep existing audit meaning and `current_operation_run_id` correlation intact where the current generators already attach reopened or refreshed findings to system workflow paths. +- **FR-255-012**: Regression proof MUST make the invariant explicit across new creation, repeated observation, and resolved-to-reopened behavior for the in-scope generator families. +- **FR-255-013**: Any database constraint or migration-based invariant enforcement beyond the existing application write paths is out of scope for this feature and MAY only be considered as a later narrow follow-up if planning proves it is compatibility-safe and materially smaller than a broader redesign. + +### Key Entities *(include if feature involves data)* + +- **Lifecycle-ready finding**: A tenant-owned finding record that is immediately usable in the existing workflow because it already has canonical lifecycle status, seen history, recurrence identity, and due/SLA truth where current policy requires it. +- **Finding generator family**: One of the active repo-owned write paths that creates or refreshes findings today: baseline compare drift, Entra admin roles, or permission posture. +- **Recurrence identity**: The existing recurrence key or fingerprint semantics that decide whether repeated observation refreshes one finding or incorrectly creates a new one. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: During regression validation, 100% of newly created in-scope findings arrive with the lifecycle data needed by the existing downstream workflow in the same observation cycle that first persists them. +- **SC-002**: During regression validation, 0 in-scope active finding paths require a separate repair, backfill, or deploy-time step before newly created or reopened findings are safe to show on existing workflow surfaces. +- **SC-003**: During regression validation, repeated observation of the same in-scope issue reuses one canonical finding identity instead of creating uncontrolled duplicates across each in-scope generator family. +- **SC-004**: During regression validation, previously resolved in-scope findings reopen through the existing workflow path with refreshed lifecycle truth across each in-scope generator family. + +## Dependencies + +- The baseline compare finding creation and recurrence path in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` +- The Entra admin roles finding creation and reopen path in `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` +- The permission posture finding creation and reopen path in `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` +- Existing shared finding lifecycle behavior such as reopen semantics and SLA/due calculation already used by those paths +- Existing focused regression proof in: + - `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php` + - `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` + - `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` + +## Assumptions + +- Spec 253 removes the visible lifecycle backfill runtime surfaces and Spec 254 removes acknowledged compatibility first, so this slice can focus only on the post-cleanup target state. +- The three verified active generator families above are the full bounded scope for this feature unless planning finds another currently active finding writer that is equally first-class and already shipping. +- Lifecycle-ready does not make owner, assignee, or additional governance fields mandatory. It only covers the existing lifecycle truth needed for current workflow readiness. +- The product remains pre-production, so historical data migration, compatibility shims, and retained repair tooling are not justified. +- Downstream findings, review, and summary surfaces should continue working without design changes if write-time truth is hardened correctly. + +## Risks + +- Another active finding writer may exist outside the three verified families and remain unsafely implicit if planning does not confirm the full set before implementation. +- Over-eager implementation could introduce a generic invariant framework or broaden into lifecycle redesign, which would violate the intended slice boundary. +- Different generator families already count repeated observation differently; forcing one artificial rule instead of preserving each family's valid observation semantics could create regressions while trying to harden the invariant. + +## Out of Scope + +- Reintroducing findings lifecycle backfill runtime surfaces, repair commands, deploy hooks, or tenant repair actions +- Removing acknowledged compatibility or changing broader findings workflow vocabulary, which is already covered by Spec 254 +- New customer-facing workflow surfaces, review inbox redesign, customer review workspace work, or localization work +- New persistence, new workflow states, new owner/assignee requirements, or broader findings lifecycle redesign +- External Support Desk / PSA Handoff work +- Cross-Tenant Compare and Promotion refresh work already tracked under Spec 043 +- Schema changes, migrations, or database constraints except as an explicit later follow-up candidate + +## Follow-up Candidates + +1. A very narrow database-level invariant guard may be considered later only if planning proves it can enforce one of these fields safely without reopening compatibility or widening the feature. +2. `External Support Desk / PSA Handoff` remains deferred until the repo names one concrete external desk or PSA target. +3. `Cross-Tenant Compare and Promotion v1` remains on the existing Spec 043 track as a refresh candidate rather than being reopened inside this hardening slice. diff --git a/specs/255-enforce-finding-creation-invariants/tasks.md b/specs/255-enforce-finding-creation-invariants/tasks.md new file mode 100644 index 00000000..0e995f59 --- /dev/null +++ b/specs/255-enforce-finding-creation-invariants/tasks.md @@ -0,0 +1,242 @@ +# Tasks: Enforce Creation-Time Finding Invariants + +**Input**: Design documents from `/specs/255-enforce-finding-creation-invariants/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/finding-creation-invariants.contract.yaml`, `checklists/requirements.md` + +**Tests (TEST-GOV-001)**: REQUIRED (Pest). Keep proof in the targeted `fast-feedback` and `confidence` lanes named in `specs/255-enforce-finding-creation-invariants/plan.md` and `specs/255-enforce-finding-creation-invariants/quickstart.md`. Keep the three writer suites as the primary proof in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, and `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`. Use only bounded adjacent regression in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`, `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`, `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` where they prove shared recurrence, consumer honesty, or unchanged trigger authorization without inflating the implementation scope into direct UI rewrites. +**Operations**: This slice does not add or change an `OperationRun` start family, does not add a new audit action ID, and must not introduce migrations, DB constraints, repair tooling, deploy hooks, or external integrations. Existing `current_operation_run_id` correlations stay contextual only where the current writers already set them, and any report-emission assertions remain bounded to the writer suites that already own them. +**RBAC**: Preserve current tenant/workspace isolation, current `404` versus `403` behavior on the baseline compare matrix and admin-roles scan trigger surfaces, and the existing tenant-scoped background/system reopen semantics. Do not add a new capability, bypass, or customer-facing workflow branch. +**UI / Surface Guardrails**: This is a `review-mandatory` write-time truth hardening slice with `standard-native-filament` relief. `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, and `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` stay regression consumers only unless existing tests prove a shared-truth fix is insufficient. +**Filament UI Action Surfaces**: No new Filament Resource, Page, RelationManager, panel, provider, or asset work is introduced. `FindingResource` already has a view page, so global-search compliance stays satisfied without new tasking. No new destructive action is introduced or changed. +**Organization**: Tasks are grouped by user story so each slice stays independently verifiable. Recommended delivery order is Phase 1 -> Phase 2 -> `US1` -> `US2` -> `US3` -> final validation, because creation-readiness must be explicit before reopen and recurrence proofs are tightened. + +**Implementation note**: If creation-time invariants converge through the three writer paths plus `FindingWorkflowService` and `FindingSlaPolicy`, keep downstream findings surfaces untouched and make proof responsibility explicit in their existing test files rather than planning direct edits to every listed consumer file. + +## Test Governance Checklist + +- [x] Lane assignment stays `fast-feedback` plus `confidence` and remains the narrowest sufficient proof for write-time lifecycle hardening. +- [x] New or changed tests stay in focused `Feature` files only; no browser or new heavy-governance family is added. +- [x] Shared helpers, factories, fixtures, and context defaults stay cheap by default; any broader setup is isolated to the findings suites that already need it. +- [x] Planned validation commands stay limited to the quickstart command set, allowing the three writer-suite commands to be combined into one equivalent Sail invocation plus the shared recurrence, consumer, and trigger-authorization checks below. +- [x] The declared surface test profile stays `standard-native-filament`; downstream findings surfaces remain proof consumers only. +- [x] Any material residue or follow-up note resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`, not as implicit scope drift. + +## Phase 1: Setup (Shared Invariant Anchors) + +**Purpose**: Lock the bounded writer inventory, shared lifecycle seams, and proving commands before implementation starts. + +- [x] T001 [P] Verify the bounded feature package, stop conditions, and non-goals across `specs/255-enforce-finding-creation-invariants/spec.md`, `specs/255-enforce-finding-creation-invariants/plan.md`, `specs/255-enforce-finding-creation-invariants/research.md`, `specs/255-enforce-finding-creation-invariants/data-model.md`, `specs/255-enforce-finding-creation-invariants/quickstart.md`, and `specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml` +- [x] T002 [P] Verify the active finding-writer and shared seam inventory across `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, `apps/platform/app/Services/Findings/FindingSlaPolicy.php`, and `apps/platform/app/Models/Finding.php` +- [x] T003 [P] Verify the narrow Sail validation commands and manual smoke expectations in `specs/255-enforce-finding-creation-invariants/plan.md` and `specs/255-enforce-finding-creation-invariants/quickstart.md` +- [x] T004 [P] Verify downstream proof-only consumers across `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`, `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, and `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php` + +**Checkpoint**: The bounded invariant target, shared seams, and validation entry points are explicit before any runtime file changes begin. + +--- + +## Phase 2: Foundational (Blocking Proof Surfaces) + +**Purpose**: Make the intended proof surfaces and adjacent cleanup guardrails explicit before the write paths are changed. + +**CRITICAL**: No user story work should begin until this phase is complete. + +- [x] T005 [P] Lock the per-family lifecycle-ready and inline-repair proof plan in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, and `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` +- [x] T006 [P] Lock the shared recurrence and reopen proof plan in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`, and `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php` +- [x] T007 [P] Audit incomplete-lifecycle fixture and helper anchors across `apps/platform/database/factories/FindingFactory.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, and `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` +- [x] T008 [P] Audit adjacent cleanup guardrails in `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php` and `apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php` so this slice does not reintroduce repair tooling or acknowledged compatibility + +**Checkpoint**: Writer-level proof, shared reopen proof, and adjacent no-regression guardrails are explicit and ready for bounded implementation work. + +--- + +## Phase 3: User Story 1 - See Ready Findings Immediately (Priority: P1) + +**Goal**: Newly detected findings arrive lifecycle-ready on first persistence across the three active writer families. + +**Independent Test**: Trigger one new finding per writer family and verify the first persisted record already carries canonical open status, seen history, ownership anchors, and due or SLA truth without any repair or second-pass workflow. + +### Tests for User Story 1 + +- [x] T009 [P] [US1] Extend baseline compare create-readiness and incomplete-field inline-repair coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php` +- [x] T010 [P] [US1] Extend Entra admin roles create-readiness and incomplete-field inline-repair coverage in `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` +- [x] T011 [P] [US1] Extend permission posture create-readiness and incomplete-field inline-repair coverage in `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` + +### Implementation for User Story 1 + +- [x] T012 [US1] Align baseline compare finding creation and active-record refresh with the lifecycle-ready contract in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` +- [x] T013 [US1] Align Entra admin roles finding creation and active-record refresh with the lifecycle-ready contract in `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` +- [x] T014 [US1] Align permission posture finding creation and active-record refresh with the lifecycle-ready contract in `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` +- [x] T015 [US1] Keep ownership anchors plus due or SLA initialization explicit without introducing a migration or repair surface in `apps/platform/app/Models/Finding.php`, `apps/platform/app/Services/Findings/FindingSlaPolicy.php`, and the three story tests from `T009` through `T011` + +**Checkpoint**: User Story 1 is independently functional and all three active writers create lifecycle-ready findings in the same write path. + +--- + +## Phase 4: User Story 2 - Reopen the Same Finding When the Risk Returns (Priority: P1) + +**Goal**: Resolved findings reopen through the existing shared workflow path with refreshed lifecycle truth and preserved canonical identity. + +**Independent Test**: Resolve an in-scope finding, re-observe the same issue through each writer family, and verify the same record reopens with cleared terminal markers and refreshed due or SLA truth. + +### Tests for User Story 2 + +- [x] T016 [P] [US2] Add baseline compare resolved-to-reopened regression and `current_operation_run_id` continuity coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php` +- [x] T017 [P] [US2] Add Entra admin roles resolved-to-reopened regression and `current_operation_run_id` continuity coverage in `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php` +- [x] T018 [P] [US2] Add permission posture resolved-to-reopened regression plus existing `current_operation_run_id` and stored-report emission continuity coverage in `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` +- [x] T019 [P] [US2] Tighten shared reopen-service proof for `reopenBySystem()` due or SLA recalculation, audit continuity, and terminal eligibility in `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php` + +### Implementation for User Story 2 + +- [x] T020 [US2] Keep reopened-state mutation on `FindingWorkflowService::reopenBySystem()` and reconcile baseline compare call sites in `apps/platform/app/Services/Findings/FindingWorkflowService.php` and `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` +- [x] T021 [US2] Preserve same-finding reopen identity and family-specific evidence refresh in `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` and `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` +- [x] T022 [US2] Reconcile reopened due-date, SLA, and resolved-marker expectations without adding new workflow states or audit dialects in `apps/platform/app/Services/Findings/FindingSlaPolicy.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, and `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php` + +**Checkpoint**: User Story 2 is independently functional and resolved findings reopen through the existing shared workflow semantics rather than duplicating records or adding a second reopen path. + +--- + +## Phase 5: User Story 3 - Keep One Canonical Finding Identity Through Repeated Detection (Priority: P2) + +**Goal**: Repeated observation strengthens the same finding record, respects each family's observation boundary, and keeps downstream surfaces truthful without widening the feature into UI redesign. + +**Independent Test**: Re-run the same observation and then a later valid observation across the in-scope families and verify that one canonical finding identity remains in place, same-observation retries do not double count, and downstream findings surfaces still read honest lifecycle truth. + +### Tests for User Story 3 + +- [x] T023 [P] [US3] Extend same-observation idempotence and canonical-identity reuse coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php` +- [x] T024 [P] [US3] Extend cross-family recurrence and observation-boundary coverage in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php` and `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php` +- [x] T025 [P] [US3] Tighten downstream consumer and trigger-authorization proof that shared lifecycle truth still renders honestly and that non-members remain `404` while in-scope capability failures remain `403` in `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` + +### Implementation for User Story 3 + +- [x] T026 [US3] Preserve family-owned recurrence keys, fingerprints, and observation boundaries while preventing duplicate canonical findings in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, and `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` +- [x] T027 [US3] Keep any shared lifecycle normalization bounded to `apps/platform/app/Services/Findings/` only when it replaces real duplication across all three writers, with proof confined to `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`, and `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php` rather than widening into new workflow or UI files + +**Checkpoint**: User Story 3 is independently functional and recurrence keeps one canonical finding identity without double-counting or forcing direct downstream surface rewrites. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Keep the slice bounded, run the narrow validation workflow, and check for out-of-scope residue. + +- [x] T028 [P] Run formatting for touched PHP files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- [x] T029 [P] Run the focused writer-suite Sail command `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php` +- [x] T030 [P] Run the focused shared-recurrence Sail command `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php` +- [x] T031 [P] Run the downstream-consumer and trigger-RBAC Sail command `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` +- [ ] T032 [P] Execute quickstart manual smoke steps 1 through 4 from `specs/255-enforce-finding-creation-invariants/quickstart.md` against `/admin/t/{tenant}/findings`, `MyFindingsInbox`, and `FindingsIntakeQueue`, then leave diff/scope review to `T034` +- [x] T033 [P] Run residue searches for `backfill`, `repair`, `constraint`, `migration`, and any new `Finding::STATUS_` additions across `apps/platform/app/`, `apps/platform/tests/`, `apps/platform/database/`, and `specs/255-enforce-finding-creation-invariants/`, then classify each remaining match as allowed shared-consumer proof, in-scope cleanup to delete now, or `reject-or-split` +- [x] T034 Verify that no file under `apps/platform/database/migrations/` changed, no new repair or rollout entry point appeared under `apps/platform/app/Console/Commands/` or `apps/platform/app/Services/Runbooks/`, and no direct workflow expansion landed in `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, or `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`; if truth-consumer proof from `T025` and `T031` suggests a direct UI edit is necessary, stop and record that as `document-in-feature` or `reject-or-split` instead of treating it as default in-scope work + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: Starts immediately and locks the exact scope, writer inventory, and proving commands. +- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until proof files, fixtures, and adjacent cleanup guardrails are explicit. +- **User Story 1 (Phase 3)**: Depends on Foundational and establishes the lifecycle-ready create contract. +- **User Story 2 (Phase 4)**: Depends on User Story 1 because reopen semantics should refresh the same lifecycle-ready contract established at creation time. +- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because recurrence and consumer proof only mean the right thing after create and reopen behavior are aligned. +- **Polish (Phase 6)**: Depends on all desired user stories being complete so final validation and residue checks run on the finished slice. + +### User Story Dependencies + +- **US1**: No dependencies beyond Foundational. +- **US2**: Depends on US1. +- **US3**: Depends on US1 and US2. + +### Within Each User Story + +- Add or update the story tests first and confirm they fail before implementation edits are considered complete. +- Keep recurrence identity family-owned instead of introducing a generic invariant framework. +- Keep downstream findings surfaces as proof consumers unless shared-truth tests prove a concrete need for direct edits. +- Keep migrations, DB constraints, repair tooling, acknowledged cleanup, external support-desk or PSA work, customer-facing workflow changes, and broad findings redesign out of scope. + +### Parallel Opportunities + +- `T001`, `T002`, `T003`, and `T004` can run in parallel during Setup. +- `T005`, `T006`, `T007`, and `T008` can run in parallel during Foundational work. +- `T009`, `T010`, and `T011` can run in parallel for User Story 1 before `T012`, `T013`, `T014`, and `T015`. +- `T016`, `T017`, `T018`, and `T019` can run in parallel for User Story 2 before `T020`, `T021`, and `T022`. +- `T023`, `T024`, and `T025` can run in parallel for User Story 3 before `T026` and `T027`. +- `T029`, `T030`, `T031`, `T032`, and `T033` can run in parallel during final validation after `T028`, followed by `T034` as the final scope-boundary check. + +--- + +## Parallel Example: User Story 1 + +```bash +# User Story 1 tests in parallel +T009 apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php +T010 apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php +T011 apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php + +# User Story 1 implementation after the tests are in place +T012 apps/platform/app/Jobs/CompareBaselineToTenantJob.php +T013 apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php +T014 apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php +``` + +## Parallel Example: User Story 2 + +```bash +# User Story 2 tests in parallel +T016 apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php +T017 apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php +T018 apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php +T019 apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php + +# User Story 2 implementation after the tests are in place +T020 apps/platform/app/Services/Findings/FindingWorkflowService.php + apps/platform/app/Jobs/CompareBaselineToTenantJob.php +T021 apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php + apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php +``` + +## Parallel Example: User Story 3 + +```bash +# User Story 3 tests in parallel +T023 apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php + apps/platform/tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php +T024 apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php + apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php +T025 apps/platform/tests/Feature/Findings/MyWorkInboxTest.php + apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php + +# User Story 3 implementation after the tests are in place +T026 apps/platform/app/Jobs/CompareBaselineToTenantJob.php + apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php + apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php +T027 apps/platform/app/Services/Findings/ +``` + +--- + +## Implementation Strategy + +### MVP First (User Stories 1 and 2) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Complete Phase 4: User Story 2. +5. Run `T028`, `T029`, and `T030` before widening into recurrence and consumer-proof cleanup. + +### Incremental Delivery + +1. Lock the bounded writer inventory, proof files, and stop conditions. +2. Make create-time lifecycle readiness explicit for baseline compare, Entra admin roles, and permission posture. +3. Preserve the shared reopen path and refresh lifecycle truth when resolved findings return. +4. Tighten recurrence, idempotence, and downstream proof without widening into UI redesign or repair tooling. +5. Finish with focused Sail validation, manual smoke, and residue checks. + +### Parallel Team Strategy + +1. One contributor can own the three writer-family tests while another confirms shared recurrence and downstream consumer proof after Phase 2. +2. After User Story 1 lands, one contributor can align the reopen path while another prepares the recurrence and consumer proof for User Story 3. +3. Finish with one bounded pass for formatting, focused Sail validation, and residue or scope-boundary review. + +--- + +## Notes + +- Suggested MVP scope: Phase 1 through Phase 4. Creation readiness without reopen reuse is not sufficient for this feature. +- Explicit non-goals remain: runtime backfill surfaces, acknowledged cleanup, new workflow states, broad findings redesign, migrations or DB constraints, repair tooling, external support-desk or PSA work, and customer-facing workflow expansion. +- Filament remains on Livewire v4.0+; no panel/provider or asset strategy changes are needed, and `apps/platform/bootstrap/providers.php` remains the relevant provider-registration location if later Filament work is ever required. +- All tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and concrete file paths. \ No newline at end of file -- 2.45.2 From 4b36d2c64f459f75a9bf25d9e201a389eb3ea0bd Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 29 Apr 2026 13:01:43 +0000 Subject: [PATCH 30/36] =?UTF-8?q?Automated=20PR:=20platform-dev=20?= =?UTF-8?q?=E2=86=92=20dev=20(#300)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated PR created by Copilot. Commit: 4b0dc2a62ec7765cf53ad05928cb914066963b18 This PR merges branch `platform-dev` into `dev`. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/300 --- docs/product/implementation-ledger.md | 67 +++++--- specs/900-policy-lifecycle/spec.md | 228 -------------------------- 2 files changed, 40 insertions(+), 255 deletions(-) delete mode 100644 specs/900-policy-lifecycle/spec.md diff --git a/docs/product/implementation-ledger.md b/docs/product/implementation-ledger.md index b5f12e4d..8bec9042 100644 --- a/docs/product/implementation-ledger.md +++ b/docs/product/implementation-ledger.md @@ -15,7 +15,7 @@ ## Purpose ## Current Product Position -TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls. Die Repo-Wahrheit liegt damit ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Review- und Portfolio-Plattform ausgereift: Customer-safe Review Consumption, Cross-Tenant-Workflows und kommerzielle Lifecycle-Reife sind noch unvollstaendig. Zusaetzlich zeigt der Repo-Stand eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden. +TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls und inzwischen repo-real umgesetzten Customer-safe Review Consumption, Risk-Acceptance/Exception-Workflow, Findings-/Governance-Inboxen und einer DE/EN-Locale-Foundation. Die Repo-Wahrheit liegt damit klar ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Portfolio- und Commercial-Plattform ausgereift: Cross-Tenant-Workflows, Compare/Promotion, Billing-/Lifecycle-Reife und Private-AI-Governance bleiben unvollstaendig. Zusaetzlich zeigt der Repo-Stand weiterhin eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden. ## Status Model @@ -41,24 +41,24 @@ ## Roadmap Coverage Summary | Roadmap Area | Status | Evidence Level | UI Ready | Tested | Sellable | Notes | |---|---|---:|---|---|---|---| | R1 Golden Master Governance | adopted | strong | yes | repo tests, not run | yes | Baselines, Drift, Findings und OperationRun-Truth sind breit im Produkt verankert. | -| R2 Tenant Reviews, Evidence & Control Foundation | adopted | strong | yes | repo tests, not run | almost | Review-, Evidence- und Control-Foundations sind stark; Customer Review Workspace fehlt noch. | +| R2 Tenant Reviews, Evidence & Control Foundation | adopted | strong | yes | repo tests, not run | yes | Reviews, Evidence, Review Packs, Customer Review Workspace und Control-/Exception-Layer greifen als reale Governance-Surface zusammen. | | Alert escalation + notification routing | implemented_verified | strong | partial | repo tests, not run | yes | Alert-Regeln, Dispatch, Cooldown und Quiet Hours sind real. | | Governance & Architecture Hardening | implemented_partial | strong | partial | repo tests, not run | foundation-only | Viele Hardening-Slices sind bereits im Code, die Lane bleibt aber aktiv. | -| UI & Product Maturity Polish | implemented_partial | medium | partial | partial repo tests, not run | no | Einzelne Polishing-Slices sind da, aber kein geschlossenes "fertig"-Signal auf Theme-Ebene. | +| UI & Product Maturity Polish | implemented_partial | strong | partial | partial repo tests, not run | no | Empty States, Navigation, Localization und read-only Review-Polish sind real, aber kein geschlossenes Theme-Completion-Signal. | | Secret & Security Hardening | implemented_verified | strong | yes | repo tests, not run | almost | Provider-Verifikation, Permission-Diagnostics und Redaction sind belastbar. | | Baseline Drift Engine (Cutover) | adopted | strong | yes | repo tests, not run | yes | Compare- und Drift-Workflow wirken als produktive Kernfunktion. | -| R1.9 Platform Localization v1 | planned | none | no | no | no | Keine belastbare Locale-Foundation im Repo gefunden. | +| R1.9 Platform Localization v1 | implemented_verified | strong | yes | repo tests, not run | foundation-only | Locale-Resolver, Override/Praeferenz, Workspace-Default, Fallback und lokalisierte Notifications sind repo-real. | | Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. | | R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. | -| R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Support und Help sind real; kundensichere Review-Consumption ist noch offen. | -| Findings Workflow v2 / Execution Layer | implemented_partial | strong | yes | repo tests, not run | almost | Triage, Ownership, Alerts und Hygiene sind vorhanden; der naechste Operator-Layer fehlt und Legacy-Cleanup um Backfill-/Status-Kompatibilitaet bleibt offen. | +| R2 Completion: customer review, support, help | adopted | strong | yes | repo tests, not run | yes | Customer Review Workspace, Support Diagnostics/Requests und Help-Katalog sind repo-real. | +| Findings Workflow v2 / Execution Layer | adopted | strong | yes | repo tests, not run | almost | Triage, Ownership, My Work, Intake, Governance Inbox, Exceptions und Alerts/Hygiene sind real; Cross-Tenant-Decisioning bleibt spaeter. | | Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. | | Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. | | Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. | | Private AI Execution & Usage Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. | | MSP Portfolio & Operations | implemented_partial | medium | partial | repo tests, not run | foundation-only | Portfolio-Triage ist da; Compare/Promotion und Decision Workboard fehlen. | -| Human-in-the-Loop Autonomous Governance | planned | none | no | no | no | Kein repo-verifizierter Decision-Pack- oder Approval-Workflow. | -| Drift & Change Governance | specified | weak | no | no | no | Einzelne Foundations existieren, die thematische Produkt-Lane aber nicht. | +| Human-in-the-Loop Autonomous Governance | planned | none | no | no | no | Kein repo-verifizierter Decision-Pack- oder Approval-Workflow jenseits des jetzigen Exception-/Review-Layers. | +| Drift & Change Governance | implemented_partial | strong | yes | repo tests, not run | almost | Drift review, accepted-risk governance, exception validity und Governance-Inbox-Surfaces sind repo-real; portfolio-weite Eskalation bleibt offen. | | Standardization & Policy Quality | planned | none | no | no | no | Keine starke Repo-Evidence fuer eine Intune-Linting- oder Policy-Quality-Oberflaeche. | | PSA / Ticketing Handoff | planned | none | no | no | no | Support Requests existieren, externe Handoff-Integration aber nicht. | @@ -69,10 +69,13 @@ ## Implemented Capabilities | OperationRun truth layer | implemented_verified | yes | partial | repo tests, not run | yes | foundation-only | `app/Models/OperationRun.php`; `tests/Feature/System/*`; `tests/Feature/ReviewPack/*` | | Baseline profiles, snapshots and compare | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/BaselineProfile.php`; `app/Models/BaselineSnapshot.php`; `app/Services/Baselines/BaselineCompareService.php` | | Drift findings and governance pressure | adopted | yes | yes | repo tests, not run | yes | yes | `app/Models/Finding.php`; `app/Filament/Widgets/Dashboard/RecentDriftFindings.php`; `tests/Feature/Findings/*` | +| Findings inboxes and governance inbox | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Filament/Pages/Findings/MyFindingsInbox.php`; `app/Filament/Pages/Findings/FindingsIntakeQueue.php`; `app/Filament/Pages/Governance/GovernanceInbox.php`; `tests/Feature/Findings/MyWorkInboxTest.php`; `tests/Feature/Governance/*` | +| Finding exceptions and risk acceptance workflow | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/FindingException.php`; `app/Services/Findings/FindingExceptionService.php`; `app/Filament/Resources/FindingExceptionResource.php`; `tests/Feature/Findings/FindingExceptionWorkflowTest.php` | | Restore workflow with safety gates | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/OperationRun.php`; restore gates and tests in `tests/Feature/Restore/*` | | Evidence snapshots | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Models/EvidenceSnapshot.php`; `app/Services/Evidence/EvidenceSnapshotService.php`; `tests/Feature/Evidence/*` | | Tenant reviews | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/TenantReview.php`; `app/Services/TenantReviews/TenantReviewService.php`; `tests/Feature/TenantReview/*` | | Review pack generation and export | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/ReviewPack.php`; `app/Services/ReviewPackService.php`; `tests/Feature/ReviewPack/*` | +| Customer review workspace | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`; `tests/Feature/Reviews/*`; `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` | | Alerts and notification routing | implemented_verified | yes | partial | repo tests, not run | yes | yes | `app/Services/Alerts/AlertDispatchService.php`; `tests/Feature/*Alert*` | | Provider health, onboarding readiness and required permissions | adopted | yes | yes | repo tests, not run | yes | almost | `app/Jobs/ProviderConnectionHealthCheckJob.php`; `app/Services/Onboarding/OnboardingLifecycleService.php`; `app/Filament/Pages/TenantRequiredPermissions.php` | | Permission posture reporting | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`; `tests/Feature/PermissionPosture/*` | @@ -81,6 +84,7 @@ ## Implemented Capabilities | Support diagnostics | adopted | yes | yes | repo tests, not run | yes | almost | `app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`; `app/Filament/Pages/TenantDashboard.php`; `tests/Feature/SupportDiagnostics/*` | | In-app support requests | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/SupportRequest.php`; `app/Support/SupportRequests/*`; `tests/Feature/SupportRequests/*` | | Product knowledge and contextual help | implemented_partial | yes | yes | repo tests, not run | partial | almost | `app/Support/ProductKnowledge/ContextualHelpCatalog.php`; `tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php` | +| Localization foundation | implemented_verified | yes | yes | repo tests, not run | partial | foundation-only | `app/Services/Localization/LocaleResolver.php`; `app/Http/Controllers/LocalizationController.php`; `tests/Feature/Localization/*` | | Product telemetry | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/ProductUsageEvent.php`; `app/Filament/System/Widgets/ProductTelemetryKpis.php`; `tests/Feature/System/ProductTelemetry/*` | | Customer health scoring | implemented_verified | yes | yes | repo tests, not run | partial | almost | `app/Filament/System/Widgets/CustomerHealthKpis.php`; `app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php`; `tests/Feature/System/CustomerHealth/*` | | Operational controls | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/OperationalControlActivation.php`; `app/Support/OperationalControls/*`; `tests/Feature/System/OpsControls/*` | @@ -99,14 +103,15 @@ ## Foundation-Only Capabilities - Canonical control catalog: starke semantische Foundation fuer Evidence, Findings und Reviews. - Stored reports substrate: wichtig fuer Reports, Evidence und Diagnostics, aber kein eigenstaendiges Produktversprechen. - Evidence snapshot substrate: tragende technische Basis fuer Reviews und Exports. +- Localization foundation: resolved locale precedence, Workspace-Default, User-Praeferenz/Override und Notification-Formatting sind real, aber Enablement statt eigener Produkt-Surface. - Operational control registry and evaluator: starke Safety-Control-Foundation, primar operatorseitig. - Customer health scoring: reale interne SaaS-Operations-Layer, aber noch keine eigenstaendige Kundenoberflaeche. - Portfolio triage continuity: sinnvoller Multi-Tenant-Unterbau, aber noch kein vollstaendiges Portfolio-Produkt. ## Partial Capabilities -- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots und Review Packs sind stark, aber ein repo-verifizierter Customer Review Workspace fehlt. -- Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer; zusaetzlich bleibt Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten. +- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots, Review Packs und der Customer Review Workspace sind repo-real, aber portfolio-weite Consumption- und Sharing-Patterns bleiben offen. +- Findings Workflow v2: Triage, Assignment, My Work, Intake, Governance Inbox, Exceptions und Notifications sind vorhanden; spaetere Cross-Tenant-Decisioning-Layer und Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten bleiben offen. - Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht. - MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen. - Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch. @@ -114,13 +119,12 @@ ## Partial Capabilities ## Planned But Not Implemented -- Platform Localization v1 - Private AI Execution & Usage Governance Foundation - Human-in-the-Loop Autonomous Governance - Standardization & Policy Quality / Intune Linting - PSA / Ticketing Handoff -- Customer Review Workspace v1 - Cross-Tenant Compare and Promotion v1 +- Policy Lifecycle / Ghost Policies - Later compliance overlays beyond the current control/evidence foundation ## Release Readiness @@ -128,8 +132,8 @@ ## Release Readiness | Release / Theme | Readiness | Notes | |---|---|---| | R1 Golden Master Governance | implemented | Die zentrale Governance- und Execution-Layer ist repo-verifiziert und breit adoptiert. | -| R2 Tenant Reviews & Evidence Packs | partially implemented | Reviews, Evidence Snapshots und Review Packs sind stark; kundensichere Consumption fehlt noch. | -| R3 MSP Portfolio OS | foundation only | Portfolio-Triage ist da, aber Compare/Promotion und Decision Workflows fehlen. | +| R2 Tenant Reviews & Evidence Packs | implemented | Reviews, Evidence Snapshots, Review Packs, Customer Review Workspace und Exception-/Accepted-Risk-Workflow sind repo-real; breitere Commercial-Polish-Themen bleiben separat. | +| R3 MSP Portfolio OS | foundation only | Portfolio-Triage und Governance-Surfaces sind da, aber Compare/Promotion und portfolio-weite Action-Layer fehlen. | | Later Compliance Light | foundation only | Canonical Controls, Evidence und Exceptions existieren als Grundlage; ein Compliance-Produkt ist nicht repo-proven. | ## Commercial Readiness @@ -138,14 +142,16 @@ ### Demo-ready - Baseline compare and drift walkthroughs - Review pack generation and export +- Customer-safe review workspace walkthroughs - Provider health, onboarding readiness and required permissions - Support diagnostics - Permission posture and Entra admin roles reporting ### Almost sellable -- Review-driven governance workflow around tenant reviews and review packs +- Review-driven governance workflow rund um Tenant Reviews, Customer Review Workspace, accepted risks und Review Packs - Baseline drift and restore governance +- Findings workflow mit persönlicher Inbox, Intake, Governance Inbox und Exception-Handling - Alerting and run visibility for governance operations - Support requests with contextual diagnostics - Provider readiness and permission posture reporting @@ -159,6 +165,7 @@ ### Foundation-only - Canonical control catalog - Stored reports substrate - Evidence snapshot substrate +- Localization foundation - Product telemetry - Customer health scoring - Operational controls @@ -166,9 +173,7 @@ ### Foundation-only ### Not sellable yet -- Customer Review Workspace v1 - Cross-Tenant Compare and Promotion v1 -- Localization v1 - Private AI Execution Governance Foundation - External Support Desk / PSA Handoff - Compliance Light product layer @@ -177,40 +182,39 @@ ## Open Gaps & Blockers | Gap | Type | Impact | Roadmap Area | Recommended Spec | |---|---|---|---|---| -| Customer-safe review workspace is missing | Release blocker | Existing review and evidence assets cannot yet be consumed as a clear customer-facing surface | R2 completion / Tenant Reviews | P0 Customer Review Workspace v1 | -| No consolidated operator decision inbox | UX blocker | Operators still move between findings, runs, alerts and portfolio surfaces to act | Findings Workflow / MSP Portfolio | P0 Decision-Based Governance Inbox v1 | +| Decisioning still spans multiple repo-real inboxes | UX blocker | My Findings, Intake, Governance Inbox und Exception Queue sind real, aber Operators springen weiter zwischen mehreren Spezial-Surfaces und es gibt noch keinen portfolio-weiten Action-Layer | Findings Workflow / MSP Portfolio | P1 Governance Decision Surface Convergence | | Findings lifecycle backfill runtime surfaces remain productized | Cleanup blocker | Runbooks, commands, capabilities and tenant actions still expose a pre-production repair path that should not ship as product truth | Findings Workflow / Legacy Removal | P1 Remove Findings Lifecycle Backfill Runtime Surfaces | | Legacy `acknowledged` status compatibility still survives | Semantics blocker | Status helpers, filters, badges, capability aliases and tests keep non-canonical workflow semantics alive | Findings Workflow / RBAC | P1 Remove Legacy Acknowledged Finding Status Compatibility | | Creation-time finding invariants are implied but not explicitly protected | Integrity blocker | Future finding generators could regress into partial lifecycle writes and recreate the need for repair tooling | Findings Workflow / Data Integrity | P1 Enforce Creation-Time Finding Invariants | | Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 | -| Localization foundation is absent | UX blocker | Product polish and DACH-readiness remain limited | R1.9 Platform Localization v1 | P1 Localization v1 | | Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity | | Support requests do not hand off to an external desk | Commercialization blocker | Support operations still depend on manual follow-through outside the product | R2 completion / Support | P2 External Support Desk / PSA Handoff | | AI governance foundation is absent | Architecture blocker | Future AI features would risk trust and policy drift if added directly | Private AI Execution & Usage Governance | P3 Private AI Execution Governance Foundation | -| Roadmap understates current repo truth | Architecture blocker | Prioritization can drift because strategy docs lag implementation | Product planning / roadmap maintenance | none - docs alignment | +| Roadmap understates current repo truth | Architecture blocker | Prioritization can drift because strategy docs still lag neuere Review-, Findings- und Localization-Surfaces | Product planning / roadmap maintenance | none - docs alignment | | Test files were not executed for this ledger update | Testing blocker | This document relies on code plus test presence, not live runtime validation | all areas | none - run targeted suites | ## Recommended Next Specs -- `P0 Customer Review Workspace v1`: turns existing reviews, evidence and review-pack outputs into a customer-safe read-only product surface. -- `P0 Decision-Based Governance Inbox v1`: consolidates existing findings, runs, alerts and triage signals into one operator work surface. +- `P1 Governance Decision Surface Convergence`: verbindet My Findings, Intake, Governance Inbox, Customer Review Workspace und Exception Queue zu weniger Operator-Journeys und bereitet die Portfolio-Ebene vor. - `P1 Remove Findings Lifecycle Backfill Runtime Surfaces`: removes visible pre-production repair tooling from runbooks, commands, actions, capabilities and deploy/runtime hooks. - `P1 Remove Legacy Acknowledged Finding Status Compatibility`: collapses findings workflow semantics onto the canonical `triaged` model and removes stale RBAC/query aliases. - `P1 Enforce Creation-Time Finding Invariants`: proves that new findings are lifecycle-ready at write time so no repair backfill has to return later. - `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action. -- `P1 Localization v1`: still absent in repo and becomes more expensive the later it lands. - `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle. - `P2 External Support Desk / PSA Handoff`: extends support requests beyond internal persistence. - `P3 Private AI Execution Governance Foundation`: should exist before feature-level AI adoption, not after it. ## Roadmap Drift Notes +- `roadmap.md` understates current R2 completion. Customer Review Workspace, published review handoff, review-pack downloads und der Finding-Exception-/Risk-Acceptance-Workflow sind bereits repo-real. +- `roadmap.md` understates findings workflow maturity. My Findings, Intake, Governance Inbox und Exception Queue existieren bereits im Repo. +- `roadmap.md` understates localization maturity. Locale resolution order, Workspace-Default, User-Praeferenz, lokalisierte Notifications und Fallback-Tests sind implementiert. - `roadmap.md` understates the current R2 control foundation. Canonical controls, stored reports, permission posture and Entra admin roles are already repo-real, not just near-term ideas. - `roadmap.md` understates product supportability. Support diagnostics, in-app support requests and contextual help already exist in the repo. - `roadmap.md` understates operational maturity. Product telemetry, customer health and operational controls are already implemented and wired into the system panel. - `roadmap.md` understates commercial foundations. A workspace entitlement resolver, plan profiles and enforcement points already exist, even though full billing-state maturity does not. -- The roadmap is stronger at describing missing customer-facing consumption than missing backend foundations. Customer Review Workspace v1, Cross-Tenant Compare and Promotion, Localization and AI Governance still look genuinely unimplemented. -- The main drift pattern is underestimation, not overestimation. The only place where optimism should still be resisted is customer-facing review maturity: internal review and evidence foundations are strong, but the repo does not yet prove a finished customer review workspace. +- The roadmap is now better at describing still-missing portfolio- und commercial-Layer than the current state of review/findings/localization implementation. Cross-Tenant Compare and Promotion, full billing-state maturity, external PSA handoff and AI Governance still look genuinely unimplemented. +- The main drift pattern is underestimation, not overestimation. Customer-facing review consumption is no longer the clearest missing piece; portfolio action and commercial lifecycle are. ## Evidence Sources @@ -227,12 +231,19 @@ ## Evidence Sources - `apps/platform/app/Filament/Pages/TenantDashboard.php` - `apps/platform/app/Filament/System/Pages/Dashboard.php` - `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` +- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` +- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` +- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` +- `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` +- `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php` Wichtige Models: - `apps/platform/app/Models/OperationRun.php` - `apps/platform/app/Models/Finding.php` - `apps/platform/app/Models/FindingException.php` +- `apps/platform/app/Models/FindingExceptionDecision.php` +- `apps/platform/app/Models/FindingExceptionEvidenceReference.php` - `apps/platform/app/Models/BaselineProfile.php` - `apps/platform/app/Models/BaselineSnapshot.php` - `apps/platform/app/Models/EvidenceSnapshot.php` @@ -251,6 +262,7 @@ ## Evidence Sources - `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php` - `apps/platform/app/Services/Baselines/BaselineCompareService.php` - `apps/platform/app/Services/Alerts/AlertDispatchService.php` +- `apps/platform/app/Services/Findings/FindingExceptionService.php` - `apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php` - `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php` - `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php` @@ -258,6 +270,7 @@ ## Evidence Sources - `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php` - `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` - `apps/platform/app/Services/Auth/CapabilityResolver.php` +- `apps/platform/app/Services/Localization/LocaleResolver.php` Wichtige Test-Anker im Repo: @@ -276,4 +289,4 @@ ## Evidence Sources ## Last Updated -2026-04-27 on branch `248-private-ai-policy-foundation` +2026-04-29 on branch `platform-dev` diff --git a/specs/900-policy-lifecycle/spec.md b/specs/900-policy-lifecycle/spec.md deleted file mode 100644 index 873c1dc3..00000000 --- a/specs/900-policy-lifecycle/spec.md +++ /dev/null @@ -1,228 +0,0 @@ -# Feature 005: Policy Lifecycle Management - -## Overview -Implement proper lifecycle management for policies that are deleted in Intune, including soft delete, UI indicators, and orphaned policy handling. - -## Problem Statement -Currently, when a policy is deleted in Intune: -- ❌ Policy remains in TenantAtlas database indefinitely -- ❌ No indication that policy no longer exists in Intune -- ❌ Backup Items reference "ghost" policies -- ❌ Users cannot distinguish between active and deleted policies - -**Discovered during**: Feature 004 manual testing (user deleted policy in Intune) - -## Goals -- **Primary**: Implement soft delete for policies removed from Intune -- **Secondary**: Show clear UI indicators for deleted policies -- **Tertiary**: Maintain referential integrity for Backup Items and Policy Versions - -## Scope -- **Policy Sync**: Detect missing policies during `SyncPoliciesJob` -- **Data Model**: Add `deleted_at`, `deleted_by` columns (Laravel Soft Delete pattern) -- **UI**: Badge indicators, filters, restore capability -- **Audit**: Log when policies are soft-deleted and restored - ---- - -## User Stories - -### User Story 1 - Automatic Soft Delete on Sync - -**As a system administrator**, I want policies deleted in Intune to be automatically marked as deleted in TenantAtlas, so that the inventory reflects the current Intune state. - -**Acceptance Criteria:** -1. **Given** a policy exists in TenantAtlas with `external_id` "abc-123", - **When** the next policy sync runs and "abc-123" is NOT returned by Graph API, - **Then** the policy is soft-deleted (sets `deleted_at = now()`) - -2. **Given** a soft-deleted policy, - **When** it re-appears in Intune (same `external_id`), - **Then** the policy is automatically restored (`deleted_at = null`) - -3. **Given** multiple policies are deleted in Intune, - **When** sync runs, - **Then** all missing policies are soft-deleted in a single transaction - ---- - -### User Story 2 - UI Indicators for Deleted Policies - -**As an admin**, I want to see clear indicators when viewing deleted policies, so I understand their status. - -**Acceptance Criteria:** -1. **Given** I view a Backup Item referencing a deleted policy, - **When** I see the policy name, - **Then** it shows a red "Deleted" badge next to the name - -2. **Given** I view the Policies list, - **When** I enable the "Show Deleted" filter, - **Then** deleted policies appear with: - - Red "Deleted" badge - - Deleted date in "Last Synced" column - - Grayed-out appearance - -3. **Given** a policy was deleted, - **When** I view the Policy detail page, - **Then** I see: - - Warning banner: "This policy was deleted from Intune on {date}" - - All data remains readable (versions, snapshots, metadata) - ---- - -### User Story 3 - Restore Workflow - -**As an admin**, I want to restore a deleted policy from backup, so I can recover accidentally deleted configurations. - -**Acceptance Criteria:** -1. **Given** I view a deleted policy's detail page, - **When** I click the "Restore to Intune" action, - **Then** the restore wizard opens pre-filled with the latest policy snapshot - -2. **Given** a policy is successfully restored to Intune, - **When** the next sync runs, - **Then** the policy is automatically undeleted in TenantAtlas (`deleted_at = null`) - ---- - -## Functional Requirements - -### Data Model - -**FR-005.1**: Policies table MUST use Laravel Soft Delete pattern: -```php -Schema::table('policies', function (Blueprint $table) { - $table->softDeletes(); // deleted_at - $table->string('deleted_by')->nullable(); // admin email who triggered deletion -}); -``` - -**FR-005.2**: Policy model MUST use `SoftDeletes` trait: -```php -use Illuminate\Database\Eloquent\SoftDeletes; - -class Policy extends Model { - use SoftDeletes; -} -``` - -### Policy Sync Behavior - -**FR-005.3**: `PolicySyncService::syncPolicies()` MUST detect missing policies: -- Collect all `external_id` values returned by Graph API -- Query existing policies for this tenant: `whereNotIn('external_id', $currentExternalIds)` -- Soft delete missing policies: `each(fn($p) => $p->delete())` - -**FR-005.4**: System MUST restore policies that re-appear: -- Check if policy exists with `Policy::withTrashed()->where('external_id', $id)->first()` -- If soft-deleted: call `$policy->restore()` -- Update `last_synced_at` timestamp - -**FR-005.5**: System MUST log audit entries: -- `policy.deleted` (when soft-deleted during sync) -- `policy.restored` (when re-appears in Intune) - -### UI Display - -**FR-005.6**: PolicyResource table MUST: -- Default query: exclude soft-deleted policies -- Add filter "Show Deleted" (includes `withTrashed()` in query) -- Show "Deleted" badge for soft-deleted policies - -**FR-005.7**: BackupItemsRelationManager MUST: -- Show "Deleted" badge when `policy->trashed()` returns true -- Allow viewing deleted policy details (read-only) - -**FR-005.8**: Policy detail view MUST: -- Show warning banner when policy is soft-deleted -- Display deletion date and reason (if available) -- Disable edit actions (policy no longer exists in Intune) - ---- - -## Non-Functional Requirements - -**NFR-005.1**: Soft delete MUST NOT break existing features: -- Backup Items keep valid foreign keys -- Policy Versions remain accessible -- Restore functionality works for deleted policies - -**NFR-005.2**: Performance: Sync detection MUST NOT cause N+1 queries: -- Use single `whereNotIn()` query to find missing policies -- Batch soft-delete operation - -**NFR-005.3**: Data retention: Soft-deleted policies MUST be retained for audit purposes (no automatic purging) - ---- - -## Implementation Plan - -### Phase 1: Data Model (30 min) -1. Create migration for `policies` soft delete columns -2. Add `SoftDeletes` trait to Policy model -3. Run migration on dev environment - -### Phase 2: Sync Logic (1 hour) -1. Update `PolicySyncService::syncPolicies()` - - Track current external IDs from Graph - - Soft delete missing policies - - Restore re-appeared policies -2. Add audit logging -3. Test with manual deletion in Intune - -### Phase 3: UI Indicators (1.5 hours) -1. Update `PolicyResource`: - - Add "Show Deleted" filter - - Add "Deleted" badge column - - Modify query to exclude deleted by default -2. Update `BackupItemsRelationManager`: - - Show "Deleted" badge for `policy->trashed()` -3. Update Policy detail view: - - Warning banner for deleted policies - - Disable edit actions - -### Phase 4: Testing (1 hour) -1. Unit tests: - - Test soft delete on sync - - Test restore on re-appearance -2. Feature tests: - - E2E sync with deleted policies - - UI filter behavior -3. Manual QA: - - Delete policy in Intune → sync → verify soft delete - - Re-create policy → sync → verify restore - -**Total Estimated Duration**: ~4-5 hours - ---- - -## Risks & Mitigations - -| Risk | Mitigation | -|------|------------| -| Foreign key constraints block soft delete | Laravel soft delete only sets timestamp, constraints remain valid | -| Bulk delete impacts performance | Use chunked queries if tenant has 1000+ policies | -| Deleted policies clutter UI | Default filter hides them, "Show Deleted" is opt-in | - ---- - -## Success Criteria -1. ✅ Policies deleted in Intune are soft-deleted in TenantAtlas within 1 sync cycle -2. ✅ Re-appearing policies are automatically restored -3. ✅ UI clearly indicates deleted status -4. ✅ Backup Items and Versions remain accessible for deleted policies -5. ✅ No breaking changes to existing features - ---- - -## Related Features -- Feature 004: Assignments & Scope Tags (discovered this issue during testing) -- Feature 001: Backup/Restore (must work with deleted policies) - ---- - -**Status**: Planned (Post-Feature 004) -**Priority**: P2 (Quality of Life improvement) -**Created**: 2025-12-22 -**Author**: AI + Ahmed -**Next Steps**: Implement after Feature 004 Phase 3 testing complete -- 2.45.2 From 7b394918cec1b03a54d0e1ca6b9f04e0d7dba84f Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 29 Apr 2026 20:53:36 +0000 Subject: [PATCH 31/36] chore(platform): merge platform-dev into dev (#302) Integrates latest TenantPilot platform changes from `platform-dev` into `dev`. This PR was created by agent on user request; do not merge automatically. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/302 --- apps/platform/.env.example | 7 + .../TenantlessOperationRunViewer.php | 165 ++++++++- .../app/Filament/Pages/TenantDashboard.php | 142 +++++++- apps/platform/app/Models/SupportRequest.php | 61 ++++ .../Services/Audit/WorkspaceAuditLogger.php | 83 +++++ .../app/Support/Audit/AuditActionId.php | 9 + .../ExternalSupportDeskHandoffService.php | 256 ++++++++++++++ .../SupportRequestSubmissionService.php | 215 +++++++++++- apps/platform/config/support_desk.php | 14 + .../factories/SupportRequestFactory.php | 4 + ...ndoff_fields_to_support_requests_table.php | 35 ++ apps/platform/lang/de/localization.php | 28 +- apps/platform/lang/en/localization.php | 28 +- ...onRunSupportRequestExternalHandoffTest.php | 148 ++++++++ ...SupportRequestExternalHandoffAuditTest.php | 140 ++++++++ ...equestExternalHandoffAuthorizationTest.php | 131 +++++++ ...enantSupportRequestExternalHandoffTest.php | 187 ++++++++++ .../ExternalSupportDeskHandoffServiceTest.php | 121 +++++++ ...SupportRequestLatestHandoffSummaryTest.php | 113 ++++++ .../checklists/requirements.md | 63 ++++ ...-support-desk-handoff.logical.openapi.yaml | 216 ++++++++++++ .../data-model.md | 161 +++++++++ .../256-external-support-desk-handoff/plan.md | 319 +++++++++++++++++ .../quickstart.md | 48 +++ .../research.md | 167 +++++++++ .../256-external-support-desk-handoff/spec.md | 331 ++++++++++++++++++ .../tasks.md | 192 ++++++++++ 27 files changed, 3365 insertions(+), 19 deletions(-) create mode 100644 apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php create mode 100644 apps/platform/config/support_desk.php create mode 100644 apps/platform/database/migrations/2026_04_29_000000_add_external_handoff_fields_to_support_requests_table.php create mode 100644 apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php create mode 100644 apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php create mode 100644 apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php create mode 100644 apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php create mode 100644 apps/platform/tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php create mode 100644 apps/platform/tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php create mode 100644 specs/256-external-support-desk-handoff/checklists/requirements.md create mode 100644 specs/256-external-support-desk-handoff/contracts/external-support-desk-handoff.logical.openapi.yaml create mode 100644 specs/256-external-support-desk-handoff/data-model.md create mode 100644 specs/256-external-support-desk-handoff/plan.md create mode 100644 specs/256-external-support-desk-handoff/quickstart.md create mode 100644 specs/256-external-support-desk-handoff/research.md create mode 100644 specs/256-external-support-desk-handoff/spec.md create mode 100644 specs/256-external-support-desk-handoff/tasks.md diff --git a/apps/platform/.env.example b/apps/platform/.env.example index 203738b3..4759a485 100644 --- a/apps/platform/.env.example +++ b/apps/platform/.env.example @@ -59,6 +59,13 @@ MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" +SUPPORT_DESK_ENABLED=false +SUPPORT_DESK_NAME="External support desk" +SUPPORT_DESK_CREATE_URL= +SUPPORT_DESK_API_TOKEN= +SUPPORT_DESK_TICKET_URL_TEMPLATE= +SUPPORT_DESK_TIMEOUT_SECONDS=5 + AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 diff --git a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index f7ae56f4..ee56279b 100644 --- a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -31,6 +31,7 @@ use App\Support\RestoreSafety\RestoreSafetyCopy; use App\Support\Rbac\UiEnforcement; use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder; +use App\Support\SupportRequests\ExternalSupportDeskHandoffService; use App\Support\SupportRequests\SupportRequestSubmissionService; use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\TenantInteractionLane; @@ -49,6 +50,7 @@ use Filament\Notifications\Notification; use Filament\Pages\Page; use Filament\Schemas\Components\EmbeddedSchema; +use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; use Illuminate\Contracts\View\View; use Illuminate\Contracts\Support\Htmlable; @@ -267,42 +269,73 @@ public function authorizeOperationRunSupportRequest(): void private function requestSupportAction(): Action { $action = Action::make('requestSupport') - ->label('Request support') + ->label(__('localization.dashboard.request_support')) ->icon('heroicon-o-paper-airplane') ->record($this->run) ->slideOver() ->stickyModalHeader() - ->modalHeading('Request support') - ->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from the current run.') - ->modalSubmitActionLabel('Submit support request') + ->modalHeading(__('localization.dashboard.support_request_heading')) + ->modalDescription(__('localization.dashboard.support_request_run_description')) + ->modalSubmitActionLabel(__('localization.dashboard.submit_request')) ->form([ Placeholder::make('primary_context') - ->label('Primary context') + ->label(__('localization.dashboard.primary_context')) ->content(fn (): string => OperationRunLinks::identifier($this->run)) ->columnSpanFull(), Placeholder::make('included_context') - ->label('Included context') + ->label(__('localization.dashboard.included_context')) ->content(fn (): string => $this->operationSupportRequestAttachmentSummary()) ->columnSpanFull(), + Placeholder::make('latest_external_handoff') + ->label(__('localization.dashboard.latest_external_handoff')) + ->content(fn (): string => $this->operationLatestSupportRequestHandoffSummary()) + ->columnSpanFull(), + Select::make('external_handoff_mode') + ->label(__('localization.dashboard.external_handoff_mode')) + ->options(fn (): array => $this->supportHandoffModeOptions()) + ->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY) + ->helperText(fn (): string => $this->supportDeskTargetAvailable() + ? __('localization.dashboard.external_handoff_mode_helper_available') + : __('localization.dashboard.external_handoff_mode_helper_unavailable')) + ->required() + ->live() + ->native(false), + Placeholder::make('handoff_mutation_scope') + ->label(__('localization.dashboard.handoff_mutation_scope')) + ->content(fn (Get $get): string => $this->externalHandoffMutationScope($get('external_handoff_mode'))) + ->columnSpanFull(), + TextInput::make('external_ticket_reference') + ->label(__('localization.dashboard.external_ticket_reference')) + ->helperText(__('localization.dashboard.external_ticket_reference_helper')) + ->required(fn (Get $get): bool => $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) + ->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable() + && $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET), + TextInput::make('external_ticket_url') + ->label(__('localization.dashboard.external_ticket_url')) + ->helperText(__('localization.dashboard.external_ticket_url_helper')) + ->url() + ->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable() + && $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) + ->columnSpanFull(), Select::make('severity') - ->label('Severity') + ->label(__('localization.dashboard.severity')) ->options(SupportRequest::severityOptions()) ->default(SupportRequest::SEVERITY_NORMAL) ->required() ->native(false), TextInput::make('summary') - ->label('Summary') + ->label(__('localization.dashboard.summary')) ->required() ->columnSpanFull(), Textarea::make('reproduction_notes') - ->label('Reproduction notes') + ->label(__('localization.dashboard.reproduction_notes')) ->rows(4) ->columnSpanFull(), TextInput::make('contact_name') - ->label('Contact name') + ->label(__('localization.dashboard.contact_name')) ->default(fn (): ?string => $this->resolveViewerActor()->name), TextInput::make('contact_email') - ->label('Contact email') + ->label(__('localization.dashboard.contact_email')) ->email() ->default(fn (): ?string => $this->resolveViewerActor()->email), ]) @@ -312,9 +345,21 @@ private function requestSupportAction(): Action $supportRequest = app(SupportRequestSubmissionService::class)->submitForOperationRun($this->run, $actor, $data); Notification::make() - ->title('Support request submitted') - ->body('Reference '.$supportRequest->internal_reference) - ->success() + ->title(__('localization.dashboard.support_request_submitted')) + ->body($this->supportRequestNotificationBody($supportRequest)) + ->when( + $supportRequest->hasExternalHandoffFailure(), + fn (Notification $notification): Notification => $notification->warning(), + fn (Notification $notification): Notification => $notification->success(), + ) + ->when( + $supportRequest->external_ticket_url !== null, + fn (Notification $notification): Notification => $notification->actions([ + Action::make('openExternalTicket') + ->label(__('localization.dashboard.open_external_ticket')) + ->url((string) $supportRequest->external_ticket_url, shouldOpenInNewTab: true), + ]), + ) ->send(); }); @@ -414,6 +459,98 @@ private function operationSupportRequestAttachmentSummary(): string : 'Only the canonical redacted run context will be attached because you cannot view support diagnostics.'; } + private function operationLatestSupportRequestHandoffSummary(): string + { + $user = $this->resolveViewerActor(); + + $summary = app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($this->run, $user); + + return $this->formatLatestHandoffSummary($summary); + } + + /** + * @return array + */ + private function supportHandoffModeOptions(): array + { + if (! $this->supportDeskTargetAvailable()) { + return [ + SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'), + ]; + } + + return [ + SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'), + SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.handoff_mode_create_external_ticket'), + SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.handoff_mode_link_existing_ticket'), + ]; + } + + private function supportDeskTargetAvailable(): bool + { + return app(ExternalSupportDeskHandoffService::class)->targetIsConfigured(); + } + + private function externalHandoffMutationScope(mixed $mode): string + { + return match ($mode) { + SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.mutation_scope_external_create'), + SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.mutation_scope_external_link'), + default => __('localization.dashboard.mutation_scope_internal_only'), + }; + } + + /** + * @param array|null $summary + */ + private function formatLatestHandoffSummary(?array $summary): string + { + if ($summary === null) { + return __('localization.dashboard.latest_external_handoff_none'); + } + + $internalReference = (string) $summary['internal_reference']; + + if (($summary['has_failure'] ?? false) === true) { + return __('localization.dashboard.latest_external_handoff_failed', [ + 'reference' => $internalReference, + 'failure' => (string) $summary['external_handoff_failure_summary'], + ]); + } + + if (($summary['has_external_link'] ?? false) === true) { + return __('localization.dashboard.latest_external_handoff_linked', [ + 'reference' => $internalReference, + 'external' => (string) $summary['external_ticket_reference'], + ]); + } + + return __('localization.dashboard.latest_external_handoff_internal_only', [ + 'reference' => $internalReference, + ]); + } + + private function supportRequestNotificationBody(SupportRequest $supportRequest): string + { + return match ($supportRequest->externalHandoffOutcome()) { + SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED => __('localization.dashboard.support_request_submitted_created', [ + 'reference' => $supportRequest->internal_reference, + 'external' => $supportRequest->external_ticket_reference, + ]), + SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED => __('localization.dashboard.support_request_submitted_linked', [ + 'reference' => $supportRequest->internal_reference, + 'external' => $supportRequest->external_ticket_reference, + ]), + SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED => __('localization.dashboard.support_request_submitted_failed', [ + 'reference' => $supportRequest->internal_reference, + 'failure' => $supportRequest->external_handoff_failure_summary, + ]), + default => __('localization.dashboard.support_request_submitted_internal_only', [ + 'reference' => $supportRequest->internal_reference, + ]), + }; + } + /** * @param array $bundle */ diff --git a/apps/platform/app/Filament/Pages/TenantDashboard.php b/apps/platform/app/Filament/Pages/TenantDashboard.php index 8af8dbe7..63b1fb61 100644 --- a/apps/platform/app/Filament/Pages/TenantDashboard.php +++ b/apps/platform/app/Filament/Pages/TenantDashboard.php @@ -21,6 +21,7 @@ use App\Support\ProductTelemetry\ProductUsageEventCatalog; use App\Support\Rbac\UiEnforcement; use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder; +use App\Support\SupportRequests\ExternalSupportDeskHandoffService; use App\Support\SupportRequests\SupportRequestSubmissionService; use Filament\Actions\Action; use Filament\Facades\Filament; @@ -30,6 +31,7 @@ use Filament\Forms\Components\Textarea; use Filament\Notifications\Notification; use Filament\Pages\Dashboard; +use Filament\Schemas\Components\Utilities\Get; use Filament\Widgets\Widget; use Filament\Widgets\WidgetConfiguration; use Illuminate\Contracts\View\View; @@ -108,6 +110,37 @@ private function requestSupportAction(): Action ->label(__('localization.dashboard.included_context')) ->content(fn (): string => $this->tenantSupportRequestAttachmentSummary()) ->columnSpanFull(), + Placeholder::make('latest_external_handoff') + ->label(__('localization.dashboard.latest_external_handoff')) + ->content(fn (): string => $this->tenantLatestSupportRequestHandoffSummary()) + ->columnSpanFull(), + Select::make('external_handoff_mode') + ->label(__('localization.dashboard.external_handoff_mode')) + ->options(fn (): array => $this->supportHandoffModeOptions()) + ->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY) + ->helperText(fn (): string => $this->supportDeskTargetAvailable() + ? __('localization.dashboard.external_handoff_mode_helper_available') + : __('localization.dashboard.external_handoff_mode_helper_unavailable')) + ->required() + ->live() + ->native(false), + Placeholder::make('handoff_mutation_scope') + ->label(__('localization.dashboard.handoff_mutation_scope')) + ->content(fn (Get $get): string => $this->externalHandoffMutationScope($get('external_handoff_mode'))) + ->columnSpanFull(), + TextInput::make('external_ticket_reference') + ->label(__('localization.dashboard.external_ticket_reference')) + ->helperText(__('localization.dashboard.external_ticket_reference_helper')) + ->required(fn (Get $get): bool => $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) + ->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable() + && $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET), + TextInput::make('external_ticket_url') + ->label(__('localization.dashboard.external_ticket_url')) + ->helperText(__('localization.dashboard.external_ticket_url_helper')) + ->url() + ->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable() + && $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) + ->columnSpanFull(), Select::make('severity') ->label(__('localization.dashboard.severity')) ->options(SupportRequest::severityOptions()) @@ -138,8 +171,20 @@ private function requestSupportAction(): Action Notification::make() ->title(__('localization.dashboard.support_request_submitted')) - ->body('Reference '.$supportRequest->internal_reference) - ->success() + ->body($this->supportRequestNotificationBody($supportRequest)) + ->when( + $supportRequest->hasExternalHandoffFailure(), + fn (Notification $notification): Notification => $notification->warning(), + fn (Notification $notification): Notification => $notification->success(), + ) + ->when( + $supportRequest->external_ticket_url !== null, + fn (Notification $notification): Notification => $notification->actions([ + Action::make('openExternalTicket') + ->label(__('localization.dashboard.open_external_ticket')) + ->url((string) $supportRequest->external_ticket_url, shouldOpenInNewTab: true), + ]), + ) ->send(); }); @@ -281,4 +326,97 @@ private function tenantSupportRequestAttachmentSummary(): string ? 'A redacted diagnostic snapshot and the canonical tenant context will be attached.' : 'Only the canonical redacted tenant context will be attached because you cannot view support diagnostics.'; } + + private function tenantLatestSupportRequestHandoffSummary(): string + { + $tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE); + $user = $this->resolveDashboardActor(); + + $summary = app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user); + + return $this->formatLatestHandoffSummary($summary); + } + + /** + * @return array + */ + private function supportHandoffModeOptions(): array + { + if (! $this->supportDeskTargetAvailable()) { + return [ + SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'), + ]; + } + + return [ + SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'), + SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.handoff_mode_create_external_ticket'), + SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.handoff_mode_link_existing_ticket'), + ]; + } + + private function supportDeskTargetAvailable(): bool + { + return app(ExternalSupportDeskHandoffService::class)->targetIsConfigured(); + } + + private function externalHandoffMutationScope(mixed $mode): string + { + return match ($mode) { + SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.mutation_scope_external_create'), + SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.mutation_scope_external_link'), + default => __('localization.dashboard.mutation_scope_internal_only'), + }; + } + + /** + * @param array|null $summary + */ + private function formatLatestHandoffSummary(?array $summary): string + { + if ($summary === null) { + return __('localization.dashboard.latest_external_handoff_none'); + } + + $internalReference = (string) $summary['internal_reference']; + + if (($summary['has_failure'] ?? false) === true) { + return __('localization.dashboard.latest_external_handoff_failed', [ + 'reference' => $internalReference, + 'failure' => (string) $summary['external_handoff_failure_summary'], + ]); + } + + if (($summary['has_external_link'] ?? false) === true) { + return __('localization.dashboard.latest_external_handoff_linked', [ + 'reference' => $internalReference, + 'external' => (string) $summary['external_ticket_reference'], + ]); + } + + return __('localization.dashboard.latest_external_handoff_internal_only', [ + 'reference' => $internalReference, + ]); + } + + private function supportRequestNotificationBody(SupportRequest $supportRequest): string + { + return match ($supportRequest->externalHandoffOutcome()) { + SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED => __('localization.dashboard.support_request_submitted_created', [ + 'reference' => $supportRequest->internal_reference, + 'external' => $supportRequest->external_ticket_reference, + ]), + SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED => __('localization.dashboard.support_request_submitted_linked', [ + 'reference' => $supportRequest->internal_reference, + 'external' => $supportRequest->external_ticket_reference, + ]), + SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED => __('localization.dashboard.support_request_submitted_failed', [ + 'reference' => $supportRequest->internal_reference, + 'failure' => $supportRequest->external_handoff_failure_summary, + ]), + default => __('localization.dashboard.support_request_submitted_internal_only', [ + 'reference' => $supportRequest->internal_reference, + ]), + }; + } } diff --git a/apps/platform/app/Models/SupportRequest.php b/apps/platform/app/Models/SupportRequest.php index 1b252355..eaf5fb5b 100644 --- a/apps/platform/app/Models/SupportRequest.php +++ b/apps/platform/app/Models/SupportRequest.php @@ -32,6 +32,20 @@ class SupportRequest extends Model public const string SEVERITY_BLOCKING = 'blocking'; + public const string EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY = 'internal_only'; + + public const string EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET = 'create_external_ticket'; + + public const string EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET = 'link_existing_ticket'; + + public const string HANDOFF_OUTCOME_INTERNAL_ONLY = 'internal_only'; + + public const string HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED = 'external_ticket_created'; + + public const string HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED = 'external_ticket_linked'; + + public const string HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED = 'external_handoff_failed'; + protected $guarded = []; /** @@ -65,6 +79,53 @@ public static function severityValues(): array return array_keys(self::severityOptions()); } + /** + * @return array + */ + public static function externalHandoffModeOptions(): array + { + return [ + self::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => 'TenantPilot only', + self::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => 'Create external ticket', + self::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => 'Link existing ticket', + ]; + } + + /** + * @return list + */ + public static function externalHandoffModeValues(): array + { + return array_keys(self::externalHandoffModeOptions()); + } + + public function hasExternalTicket(): bool + { + return is_string($this->external_ticket_reference) && trim($this->external_ticket_reference) !== ''; + } + + public function hasExternalHandoffFailure(): bool + { + return is_string($this->external_handoff_failure_summary) && trim($this->external_handoff_failure_summary) !== ''; + } + + public function externalHandoffOutcome(): string + { + if ($this->hasExternalHandoffFailure()) { + return self::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED; + } + + if (! $this->hasExternalTicket()) { + return self::HANDOFF_OUTCOME_INTERNAL_ONLY; + } + + return match ($this->external_handoff_mode) { + self::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => self::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED, + self::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => self::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED, + default => self::HANDOFF_OUTCOME_INTERNAL_ONLY, + }; + } + /** * @return list */ diff --git a/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php b/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php index 0cc9bcc3..51b14aef 100644 --- a/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php +++ b/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php @@ -173,4 +173,87 @@ public function logSupportRequestCreated( tenant: $tenant, ); } + + public function logSupportRequestExternalTicketCreated( + SupportRequest $supportRequest, + User|PlatformUser|null $actor = null, + ): \App\Models\AuditLog { + return $this->logSupportRequestExternalHandoff( + supportRequest: $supportRequest, + actor: $actor, + action: AuditActionId::SupportRequestExternalTicketCreated, + status: 'success', + summaryPrefix: 'External ticket created for support request ', + ); + } + + public function logSupportRequestExternalTicketLinked( + SupportRequest $supportRequest, + User|PlatformUser|null $actor = null, + ): \App\Models\AuditLog { + return $this->logSupportRequestExternalHandoff( + supportRequest: $supportRequest, + actor: $actor, + action: AuditActionId::SupportRequestExternalTicketLinked, + status: 'success', + summaryPrefix: 'External ticket linked for support request ', + ); + } + + public function logSupportRequestExternalHandoffFailed( + SupportRequest $supportRequest, + User|PlatformUser|null $actor = null, + ): \App\Models\AuditLog { + return $this->logSupportRequestExternalHandoff( + supportRequest: $supportRequest, + actor: $actor, + action: AuditActionId::SupportRequestExternalHandoffFailed, + status: 'failed', + summaryPrefix: 'External handoff failed for support request ', + ); + } + + private function logSupportRequestExternalHandoff( + SupportRequest $supportRequest, + User|PlatformUser|null $actor, + AuditActionId $action, + string $status, + string $summaryPrefix, + ): \App\Models\AuditLog { + $supportRequest->loadMissing(['tenant.workspace']); + + $tenant = $supportRequest->tenant; + + if (! $tenant instanceof Tenant) { + throw new InvalidArgumentException('Support requests must belong to a tenant.'); + } + + $metadata = [ + 'internal_reference' => $supportRequest->internal_reference, + 'primary_context_type' => $supportRequest->primary_context_type, + 'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN + ? (string) $supportRequest->operation_run_id + : (string) $tenant->getKey(), + 'external_handoff_mode' => $supportRequest->external_handoff_mode, + 'external_ticket_reference' => $supportRequest->external_ticket_reference, + ]; + + if ($supportRequest->external_handoff_failure_summary !== null) { + $metadata['external_handoff_failure_summary'] = $supportRequest->external_handoff_failure_summary; + } + + return $this->log( + workspace: $tenant->workspace, + action: $action, + context: $metadata, + actor: $actor, + status: $status, + resourceType: 'support_request', + resourceId: (string) $supportRequest->getKey(), + targetLabel: $supportRequest->internal_reference, + summary: $summaryPrefix.$supportRequest->internal_reference, + operationRunId: $supportRequest->operation_run_id !== null ? (int) $supportRequest->operation_run_id : null, + tenant: $tenant, + ); + } } diff --git a/apps/platform/app/Support/Audit/AuditActionId.php b/apps/platform/app/Support/Audit/AuditActionId.php index 1e17bc1b..5dd9bd45 100644 --- a/apps/platform/app/Support/Audit/AuditActionId.php +++ b/apps/platform/app/Support/Audit/AuditActionId.php @@ -103,6 +103,9 @@ enum AuditActionId: string case SupportDiagnosticsOpened = 'support_diagnostics.opened'; case SupportRequestCreated = 'support_request.created'; + case SupportRequestExternalTicketCreated = 'support_request.external_ticket_created'; + case SupportRequestExternalTicketLinked = 'support_request.external_ticket_linked'; + case SupportRequestExternalHandoffFailed = 'support_request.external_handoff_failed'; case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated'; case OperationalControlPaused = 'operational_control.paused'; case OperationalControlUpdated = 'operational_control.updated'; @@ -248,6 +251,9 @@ private static function labels(): array self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed', self::SupportDiagnosticsOpened->value => 'Support diagnostics opened', self::SupportRequestCreated->value => 'Support request created', + self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created', + self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked', + self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed', self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated', self::OperationalControlPaused->value => 'Operational control paused', self::OperationalControlUpdated->value => 'Operational control updated', @@ -338,6 +344,9 @@ private static function summaries(): array self::ReviewPackDownloaded->value => 'Review pack downloaded', self::SupportDiagnosticsOpened->value => 'Support diagnostics opened', self::SupportRequestCreated->value => 'Support request created', + self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created', + self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked', + self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed', self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated', self::OperationalControlPaused->value => 'Operational control paused', self::OperationalControlUpdated->value => 'Operational control updated', diff --git a/apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php b/apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php new file mode 100644 index 00000000..7e37e657 --- /dev/null +++ b/apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php @@ -0,0 +1,256 @@ +targetIsConfigured()) { + return $this->failed('External support desk target is not configured.'); + } + + try { + $response = Http::timeout($this->timeoutSeconds()) + ->acceptJson() + ->asJson() + ->withHeaders($this->headers()) + ->post($this->createUrl(), $this->payloadFor($supportRequest)); + } catch (ConnectionException) { + return $this->failed('External support desk did not respond before the configured timeout.'); + } catch (RequestException $exception) { + return $this->failed('External support desk rejected the ticket create request (HTTP '.$exception->response->status().').'); + } + + if (! $response->successful()) { + return $this->failed('External support desk rejected the ticket create request (HTTP '.$response->status().').'); + } + + $responsePayload = $response->json(); + $responsePayload = is_array($responsePayload) ? $responsePayload : []; + + $reference = $this->normalizeReference( + data_get($responsePayload, 'ticket_reference') + ?? data_get($responsePayload, 'external_ticket_reference') + ?? data_get($responsePayload, 'reference') + ?? data_get($responsePayload, 'key') + ?? data_get($responsePayload, 'id'), + throwOnInvalid: false, + ); + + if ($reference === null) { + return $this->failed('External support desk did not return a ticket reference.'); + } + + $url = $this->normalizeUrl( + data_get($responsePayload, 'ticket_url') + ?? data_get($responsePayload, 'external_ticket_url') + ?? data_get($responsePayload, 'url') + ?? data_get($responsePayload, 'web_url') + ?? data_get($responsePayload, 'html_url'), + throwOnInvalid: false, + ) ?? $this->urlFromTemplate($reference); + + return [ + 'successful' => true, + 'external_ticket_reference' => $reference, + 'external_ticket_url' => $url, + 'failure_summary' => null, + ]; + } + + /** + * @return array{external_ticket_reference: string, external_ticket_url: ?string} + */ + public function normalizeLinkedTicket(mixed $reference, mixed $url): array + { + $normalizedReference = $this->normalizeReference($reference, throwOnInvalid: true); + + if ($normalizedReference === null) { + throw ValidationException::withMessages([ + 'external_ticket_reference' => 'The external ticket reference field is required.', + ]); + } + + return [ + 'external_ticket_reference' => $normalizedReference, + 'external_ticket_url' => $this->normalizeUrl($url, throwOnInvalid: true) ?? $this->urlFromTemplate($normalizedReference), + ]; + } + + public function targetIsConfigured(): bool + { + return (bool) config('support_desk.target.enabled', false) + && $this->createUrl() !== null; + } + + public function targetName(): string + { + $name = config('support_desk.target.name', 'External support desk'); + + return is_string($name) && trim($name) !== '' + ? trim($name) + : 'External support desk'; + } + + public function timeoutSeconds(): int + { + $configured = config('support_desk.target.timeout_seconds', self::MAX_TIMEOUT_SECONDS); + + $seconds = is_numeric($configured) ? (int) $configured : self::MAX_TIMEOUT_SECONDS; + + return max(1, min($seconds, self::MAX_TIMEOUT_SECONDS)); + } + + /** + * @return array{successful: false, external_ticket_reference: null, external_ticket_url: null, failure_summary: string} + */ + private function failed(string $summary): array + { + return [ + 'successful' => false, + 'external_ticket_reference' => null, + 'external_ticket_url' => null, + 'failure_summary' => $this->boundedFailureSummary($summary), + ]; + } + + private function createUrl(): ?string + { + return $this->normalizeUrl(config('support_desk.target.create_url'), throwOnInvalid: false); + } + + /** + * @return array + */ + private function headers(): array + { + $headers = []; + $token = config('support_desk.target.api_token'); + + if (is_string($token) && trim($token) !== '') { + $headers['Authorization'] = 'Bearer '.trim($token); + } + + return $headers; + } + + /** + * @return array + */ + private function payloadFor(SupportRequest $supportRequest): array + { + return [ + 'support_request' => [ + 'internal_reference' => $supportRequest->internal_reference, + 'severity' => $supportRequest->severity, + 'summary' => $supportRequest->summary, + 'reproduction_notes' => $supportRequest->reproduction_notes, + 'contact_name' => $supportRequest->contact_name, + 'contact_email' => $supportRequest->contact_email, + 'primary_context_type' => $supportRequest->primary_context_type, + 'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN + ? $supportRequest->operation_run_id + : $supportRequest->tenant_id, + 'workspace_id' => $supportRequest->workspace_id, + 'tenant_id' => $supportRequest->tenant_id, + 'operation_run_id' => $supportRequest->operation_run_id, + ], + 'context_envelope' => $supportRequest->context_envelope, + ]; + } + + private function normalizeReference(mixed $value, bool $throwOnInvalid): ?string + { + if (! is_string($value) && ! is_numeric($value)) { + return null; + } + + $reference = trim((string) $value); + + if ($reference === '') { + return null; + } + + if (mb_strlen($reference) > 128 || preg_match('/\A[A-Za-z0-9][A-Za-z0-9._:-]*\z/', $reference) !== 1) { + if ($throwOnInvalid) { + throw ValidationException::withMessages([ + 'external_ticket_reference' => 'The external ticket reference format is invalid.', + ]); + } + + return null; + } + + return $reference; + } + + private function normalizeUrl(mixed $value, bool $throwOnInvalid): ?string + { + if (! is_string($value)) { + return null; + } + + $url = trim($value); + + if ($url === '') { + return null; + } + + $scheme = parse_url($url, PHP_URL_SCHEME); + + if (! in_array($scheme, ['http', 'https'], true) || filter_var($url, FILTER_VALIDATE_URL) === false) { + if ($throwOnInvalid) { + throw ValidationException::withMessages([ + 'external_ticket_url' => 'The external ticket URL must be a valid HTTP or HTTPS URL.', + ]); + } + + return null; + } + + return $url; + } + + private function urlFromTemplate(string $reference): ?string + { + $template = config('support_desk.target.ticket_url_template'); + + if (! is_string($template) || trim($template) === '') { + return null; + } + + $url = str_replace( + ['{reference}', '{ticket}'], + rawurlencode($reference), + trim($template), + ); + + return $this->normalizeUrl($url, throwOnInvalid: false); + } + + private function boundedFailureSummary(string $summary): string + { + $summary = trim(preg_replace('/\s+/', ' ', $summary) ?? $summary); + + return mb_substr($summary, 0, 500); + } +} diff --git a/apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php b/apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php index 48e51f00..2083c91c 100644 --- a/apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php +++ b/apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php @@ -20,6 +20,7 @@ public function __construct( private readonly CapabilityResolver $capabilityResolver, private readonly SupportRequestContextBuilder $supportRequestContextBuilder, private readonly SupportRequestReferenceGenerator $supportRequestReferenceGenerator, + private readonly ExternalSupportDeskHandoffService $externalSupportDeskHandoffService, private readonly WorkspaceAuditLogger $workspaceAuditLogger, ) {} @@ -95,7 +96,7 @@ private function submit( $contactEmail = $validated['contact_email'] ?? $this->normalizeNullableString($actor->email); $connection = SupportRequest::query()->getModel()->getConnection(); - return $connection->transaction(function () use ( + $supportRequest = $connection->transaction(function () use ( $actor, $contactEmail, $contactName, @@ -127,6 +128,181 @@ private function submit( return $supportRequest; }); + + return $this->finalizeExternalHandoff($supportRequest, $actor, $validated); + } + + /** + * @param array{ + * external_handoff_mode: string, + * external_ticket_reference: ?string, + * external_ticket_url: ?string + * } $validated + */ + private function finalizeExternalHandoff(SupportRequest $supportRequest, User $actor, array $validated): SupportRequest + { + $mode = $validated['external_handoff_mode']; + + if ($mode === SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY) { + $supportRequest->forceFill([ + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY, + 'external_ticket_reference' => null, + 'external_ticket_url' => null, + 'external_handoff_failure_summary' => null, + ])->save(); + + return $supportRequest->refresh(); + } + + if ($mode === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) { + $linkedTicket = $this->externalSupportDeskHandoffService->normalizeLinkedTicket( + $validated['external_ticket_reference'], + $validated['external_ticket_url'], + ); + + $supportRequest->forceFill([ + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => $linkedTicket['external_ticket_reference'], + 'external_ticket_url' => $linkedTicket['external_ticket_url'], + 'external_handoff_failure_summary' => null, + ])->save(); + + $supportRequest = $supportRequest->refresh(); + $this->workspaceAuditLogger->logSupportRequestExternalTicketLinked($supportRequest, $actor); + + return $supportRequest; + } + + $createdTicket = $this->externalSupportDeskHandoffService->createTicket($supportRequest); + + if ($createdTicket['successful']) { + $supportRequest->forceFill([ + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + 'external_ticket_reference' => $createdTicket['external_ticket_reference'], + 'external_ticket_url' => $createdTicket['external_ticket_url'], + 'external_handoff_failure_summary' => null, + ])->save(); + + $supportRequest = $supportRequest->refresh(); + $this->workspaceAuditLogger->logSupportRequestExternalTicketCreated($supportRequest, $actor); + + return $supportRequest; + } + + $supportRequest->forceFill([ + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + 'external_ticket_reference' => null, + 'external_ticket_url' => null, + 'external_handoff_failure_summary' => $createdTicket['failure_summary'], + ])->save(); + + $supportRequest = $supportRequest->refresh(); + $this->workspaceAuditLogger->logSupportRequestExternalHandoffFailed($supportRequest, $actor); + + return $supportRequest; + } + + /** + * @return array{ + * internal_reference: string, + * primary_context_type: string, + * primary_context_id: int|null, + * submitted_at: ?string, + * external_handoff_mode: string, + * external_ticket_reference: ?string, + * external_ticket_url: ?string, + * external_handoff_failure_summary: ?string, + * has_external_link: bool, + * has_failure: bool + * }|null + */ + public function latestTenantHandoffSummary(Tenant $tenant, User $actor): ?array + { + $this->authorizeCreation($tenant, $actor); + + $supportRequest = SupportRequest::query() + ->where('workspace_id', (int) $tenant->workspace_id) + ->where('tenant_id', (int) $tenant->getKey()) + ->where('primary_context_type', SupportRequest::PRIMARY_CONTEXT_TENANT) + ->latest('created_at') + ->latest('id') + ->first(); + + return $supportRequest instanceof SupportRequest + ? $this->summaryFor($supportRequest) + : null; + } + + /** + * @return array{ + * internal_reference: string, + * primary_context_type: string, + * primary_context_id: int|null, + * submitted_at: ?string, + * external_handoff_mode: string, + * external_ticket_reference: ?string, + * external_ticket_url: ?string, + * external_handoff_failure_summary: ?string, + * has_external_link: bool, + * has_failure: bool + * }|null + */ + public function latestOperationRunHandoffSummary(OperationRun $run, User $actor): ?array + { + $run->loadMissing('tenant.workspace'); + + $tenant = $run->tenant; + + if (! $tenant instanceof Tenant) { + abort(404); + } + + $this->authorizeCreation($tenant, $actor); + + $supportRequest = SupportRequest::query() + ->where('workspace_id', (int) $run->workspace_id) + ->where('tenant_id', (int) $tenant->getKey()) + ->where('primary_context_type', SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN) + ->where('operation_run_id', (int) $run->getKey()) + ->latest('created_at') + ->latest('id') + ->first(); + + return $supportRequest instanceof SupportRequest + ? $this->summaryFor($supportRequest) + : null; + } + + /** + * @return array{ + * internal_reference: string, + * primary_context_type: string, + * primary_context_id: int|null, + * submitted_at: ?string, + * external_handoff_mode: string, + * external_ticket_reference: ?string, + * external_ticket_url: ?string, + * external_handoff_failure_summary: ?string, + * has_external_link: bool, + * has_failure: bool + * } + */ + private function summaryFor(SupportRequest $supportRequest): array + { + return [ + 'internal_reference' => (string) $supportRequest->internal_reference, + 'primary_context_type' => (string) $supportRequest->primary_context_type, + 'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN + ? (is_numeric($supportRequest->operation_run_id) ? (int) $supportRequest->operation_run_id : null) + : (is_numeric($supportRequest->tenant_id) ? (int) $supportRequest->tenant_id : null), + 'submitted_at' => $supportRequest->created_at?->toIso8601String(), + 'external_handoff_mode' => (string) ($supportRequest->external_handoff_mode ?? SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY), + 'external_ticket_reference' => $this->normalizeNullableString($supportRequest->external_ticket_reference), + 'external_ticket_url' => $this->normalizeNullableString($supportRequest->external_ticket_url), + 'external_handoff_failure_summary' => $this->normalizeNullableString($supportRequest->external_handoff_failure_summary), + 'has_external_link' => $supportRequest->hasExternalTicket(), + 'has_failure' => $supportRequest->hasExternalHandoffFailure(), + ]; } /** @@ -137,10 +313,20 @@ private function submit( * reproduction_notes: ?string, * contact_name: ?string, * contact_email: ?string, + * external_handoff_mode: string, + * external_ticket_reference: ?string, + * external_ticket_url: ?string, * } */ private function validate(array $data): array { + $requestedHandoffMode = $this->normalizeNullableString($data['external_handoff_mode'] ?? null) + ?? SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY; + + if (! $this->externalSupportDeskHandoffService->targetIsConfigured()) { + $requestedHandoffMode = SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY; + } + $validated = validator( [ 'severity' => $data['severity'] ?? SupportRequest::SEVERITY_NORMAL, @@ -148,6 +334,9 @@ private function validate(array $data): array 'reproduction_notes' => $data['reproduction_notes'] ?? null, 'contact_name' => $data['contact_name'] ?? null, 'contact_email' => $data['contact_email'] ?? null, + 'external_handoff_mode' => $requestedHandoffMode, + 'external_ticket_reference' => $data['external_ticket_reference'] ?? null, + 'external_ticket_url' => $data['external_ticket_url'] ?? null, ], [ 'severity' => ['required', 'string', Rule::in(SupportRequest::severityValues())], @@ -155,6 +344,9 @@ private function validate(array $data): array 'reproduction_notes' => ['nullable', 'string'], 'contact_name' => ['nullable', 'string'], 'contact_email' => ['nullable', 'email'], + 'external_handoff_mode' => ['required', 'string', Rule::in(SupportRequest::externalHandoffModeValues())], + 'external_ticket_reference' => ['nullable', 'string', 'max:255'], + 'external_ticket_url' => ['nullable', 'url', 'max:2048'], ], )->validate(); @@ -169,6 +361,27 @@ private function validate(array $data): array $validated['reproduction_notes'] = $this->normalizeNullableString($validated['reproduction_notes'] ?? null); $validated['contact_name'] = $this->normalizeNullableString($validated['contact_name'] ?? null); $validated['contact_email'] = $this->normalizeNullableString($validated['contact_email'] ?? null); + $validated['external_ticket_reference'] = $this->normalizeNullableString($validated['external_ticket_reference'] ?? null); + $validated['external_ticket_url'] = $this->normalizeNullableString($validated['external_ticket_url'] ?? null); + + if ($validated['external_handoff_mode'] === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET + && $validated['external_ticket_reference'] === null) { + throw ValidationException::withMessages([ + 'external_ticket_reference' => 'The external ticket reference field is required.', + ]); + } + + if ($validated['external_handoff_mode'] === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) { + $this->externalSupportDeskHandoffService->normalizeLinkedTicket( + $validated['external_ticket_reference'], + $validated['external_ticket_url'], + ); + } + + if ($validated['external_handoff_mode'] !== SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) { + $validated['external_ticket_reference'] = null; + $validated['external_ticket_url'] = null; + } return $validated; } diff --git a/apps/platform/config/support_desk.php b/apps/platform/config/support_desk.php new file mode 100644 index 00000000..5f519478 --- /dev/null +++ b/apps/platform/config/support_desk.php @@ -0,0 +1,14 @@ + [ + 'enabled' => (bool) env('SUPPORT_DESK_ENABLED', false), + 'name' => env('SUPPORT_DESK_NAME', 'External support desk'), + 'create_url' => env('SUPPORT_DESK_CREATE_URL'), + 'api_token' => env('SUPPORT_DESK_API_TOKEN'), + 'ticket_url_template' => env('SUPPORT_DESK_TICKET_URL_TEMPLATE'), + 'timeout_seconds' => (int) env('SUPPORT_DESK_TIMEOUT_SECONDS', 5), + ], +]; diff --git a/apps/platform/database/factories/SupportRequestFactory.php b/apps/platform/database/factories/SupportRequestFactory.php index 39d51edd..b49a4951 100644 --- a/apps/platform/database/factories/SupportRequestFactory.php +++ b/apps/platform/database/factories/SupportRequestFactory.php @@ -51,6 +51,10 @@ public function definition(): array ], 'omissions' => [], ], + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY, + 'external_ticket_reference' => null, + 'external_ticket_url' => null, + 'external_handoff_failure_summary' => null, ]; } diff --git a/apps/platform/database/migrations/2026_04_29_000000_add_external_handoff_fields_to_support_requests_table.php b/apps/platform/database/migrations/2026_04_29_000000_add_external_handoff_fields_to_support_requests_table.php new file mode 100644 index 00000000..e0f37851 --- /dev/null +++ b/apps/platform/database/migrations/2026_04_29_000000_add_external_handoff_fields_to_support_requests_table.php @@ -0,0 +1,35 @@ +string('external_handoff_mode') + ->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY) + ->after('context_envelope'); + $table->string('external_ticket_reference')->nullable()->after('external_handoff_mode'); + $table->text('external_ticket_url')->nullable()->after('external_ticket_reference'); + $table->text('external_handoff_failure_summary')->nullable()->after('external_ticket_url'); + }); + } + + public function down(): void + { + Schema::table('support_requests', function (Blueprint $table): void { + $table->dropColumn([ + 'external_handoff_mode', + 'external_ticket_reference', + 'external_ticket_url', + 'external_handoff_failure_summary', + ]); + }); + } +}; diff --git a/apps/platform/lang/de/localization.php b/apps/platform/lang/de/localization.php index 4827d589..a062fd7e 100644 --- a/apps/platform/lang/de/localization.php +++ b/apps/platform/lang/de/localization.php @@ -80,14 +80,40 @@ 'request_support' => 'Support anfragen', 'support_request_heading' => 'Support anfragen', 'support_request_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus bestehenden Datensätzen hinzu.', - 'submit_request' => 'Anfrage senden', + 'support_request_run_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus dem aktuellen Lauf hinzu.', + 'submit_request' => 'Supportanfrage senden', + 'primary_context' => 'Primärer Kontext', 'included_context' => 'Enthaltener Kontext', + 'latest_external_handoff' => 'Letzte externe Übergabe', + 'latest_external_handoff_none' => 'Für diesen Kontext wurde noch keine Supportanfrage gesendet.', + 'latest_external_handoff_internal_only' => 'Die letzte Supportreferenz :reference ist nur in TenantPilot erfasst. Es ist noch kein externes Ticket verknüpft.', + 'latest_external_handoff_linked' => 'Die letzte Supportreferenz :reference ist mit externem Ticket :external verknüpft.', + 'latest_external_handoff_failed' => 'Die letzte Supportreferenz :reference hat kein externes Ticket, weil die Übergabe fehlgeschlagen ist: :failure', + 'external_handoff_mode' => 'Externe Übergabe', + 'handoff_mode_internal_only' => 'Nur TenantPilot', + 'handoff_mode_create_external_ticket' => 'Externes Ticket erstellen', + 'handoff_mode_link_existing_ticket' => 'Bestehendes Ticket verknüpfen', + 'external_handoff_mode_helper_available' => 'Wählen Sie, ob diese Anfrage intern bleibt, ein externes Ticket erstellt oder ein bestehendes Ticket verknüpft.', + 'external_handoff_mode_helper_unavailable' => 'Es ist kein externes Support-Desk-Ziel konfiguriert. Diese Anfrage bleibt intern.', + 'handoff_mutation_scope' => 'Änderungsumfang', + 'mutation_scope_internal_only' => 'Nur TenantPilot. Es wird kein externes Support-Desk-Ticket erstellt oder verknüpft.', + 'mutation_scope_external_create' => 'TenantPilot + externes Support Desk. TenantPilot erstellt zuerst die interne Supportanfrage und danach genau ein externes Ticket.', + 'mutation_scope_external_link' => 'TenantPilot + externes Support Desk. TenantPilot speichert die angegebene externe Ticketreferenz und erstellt kein Duplikat.', + 'external_ticket_reference' => 'Externe Ticketreferenz', + 'external_ticket_reference_helper' => 'Verwenden Sie den bestehenden Desk-Ticketschlüssel, zum Beispiel PSA-12345.', + 'external_ticket_url' => 'Externe Ticket-URL', + 'external_ticket_url_helper' => 'Optionaler HTTP- oder HTTPS-Link zum bestehenden externen Ticket.', 'severity' => 'Schweregrad', 'summary' => 'Zusammenfassung', 'reproduction_notes' => 'Reproduktionshinweise', 'contact_name' => 'Kontaktname', 'contact_email' => 'Kontakt-E-Mail', 'support_request_submitted' => 'Supportanfrage gesendet', + 'support_request_submitted_internal_only' => 'Supportreferenz :reference wurde erstellt. Es ist noch kein externes Ticket verknüpft.', + 'support_request_submitted_created' => 'Supportreferenz :reference wurde erstellt und externes Ticket :external wurde angelegt.', + 'support_request_submitted_linked' => 'Supportreferenz :reference wurde erstellt und mit externem Ticket :external verknüpft.', + 'support_request_submitted_failed' => 'Supportreferenz :reference wurde erstellt, aber die externe Übergabe ist fehlgeschlagen: :failure', + 'open_external_ticket' => 'Externes Ticket öffnen', 'open_support_diagnostics' => 'Supportdiagnosen öffnen', 'support_diagnostics' => 'Supportdiagnosen', 'support_diagnostics_description' => 'Redaktionell bereinigter Tenant-Kontext aus bestehenden Datensätzen.', diff --git a/apps/platform/lang/en/localization.php b/apps/platform/lang/en/localization.php index 8d0869b9..45928412 100644 --- a/apps/platform/lang/en/localization.php +++ b/apps/platform/lang/en/localization.php @@ -80,14 +80,40 @@ 'request_support' => 'Request support', 'support_request_heading' => 'Request support', 'support_request_description' => 'Share a concise summary and TenantAtlas will attach redacted context from existing records.', - 'submit_request' => 'Submit request', + 'support_request_run_description' => 'Share a concise summary and TenantAtlas will attach redacted context from the current run.', + 'submit_request' => 'Submit support request', + 'primary_context' => 'Primary context', 'included_context' => 'Included context', + 'latest_external_handoff' => 'Latest external handoff', + 'latest_external_handoff_none' => 'No support request has been submitted for this context yet.', + 'latest_external_handoff_internal_only' => 'Latest support reference :reference is TenantPilot only. No external ticket is linked yet.', + 'latest_external_handoff_linked' => 'Latest support reference :reference is linked to external ticket :external.', + 'latest_external_handoff_failed' => 'Latest support reference :reference has no external ticket because handoff failed: :failure', + 'external_handoff_mode' => 'External handoff', + 'handoff_mode_internal_only' => 'TenantPilot only', + 'handoff_mode_create_external_ticket' => 'Create external ticket', + 'handoff_mode_link_existing_ticket' => 'Link existing ticket', + 'external_handoff_mode_helper_available' => 'Choose whether this request stays internal, creates an external ticket, or links an existing one.', + 'external_handoff_mode_helper_unavailable' => 'No external support desk target is configured. This request will stay internal.', + 'handoff_mutation_scope' => 'Mutation scope', + 'mutation_scope_internal_only' => 'TenantPilot only. No external support desk ticket will be created or linked.', + 'mutation_scope_external_create' => 'TenantPilot + external support desk. TenantPilot creates the internal support request first, then creates one external ticket.', + 'mutation_scope_external_link' => 'TenantPilot + external support desk. TenantPilot stores the external ticket reference you provide and does not create a duplicate ticket.', + 'external_ticket_reference' => 'External ticket reference', + 'external_ticket_reference_helper' => 'Use the existing desk ticket key, for example PSA-12345.', + 'external_ticket_url' => 'External ticket URL', + 'external_ticket_url_helper' => 'Optional HTTP or HTTPS link to the existing external ticket.', 'severity' => 'Severity', 'summary' => 'Summary', 'reproduction_notes' => 'Reproduction notes', 'contact_name' => 'Contact name', 'contact_email' => 'Contact email', 'support_request_submitted' => 'Support request submitted', + 'support_request_submitted_internal_only' => 'Support reference :reference was created. No external ticket is linked yet.', + 'support_request_submitted_created' => 'Support reference :reference was created and external ticket :external was created.', + 'support_request_submitted_linked' => 'Support reference :reference was created and linked to external ticket :external.', + 'support_request_submitted_failed' => 'Support reference :reference was created, but external handoff failed: :failure', + 'open_external_ticket' => 'Open external ticket', 'open_support_diagnostics' => 'Open support diagnostics', 'support_diagnostics' => 'Support diagnostics', 'support_diagnostics_description' => 'Redacted tenant context from existing records.', diff --git a/apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php b/apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php new file mode 100644 index 00000000..34ce9748 --- /dev/null +++ b/apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php @@ -0,0 +1,148 @@ + array_merge([ + 'enabled' => true, + 'name' => 'Spec 256 Desk', + 'create_url' => 'https://desk.example.test/api/tickets', + 'api_token' => null, + 'ticket_url_template' => 'https://desk.example.test/tickets/{reference}', + 'timeout_seconds' => 5, + ], $overrides), + ]); +} + +function spec256RunHandoffComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable +{ + test()->actingAs($user); + Filament::setTenant(null, true); + session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id); + + return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]); +} + +function spec256OperationRun(Tenant $tenant): OperationRun +{ + return OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'summary_counts' => [ + 'total' => 0, + 'processed' => 0, + ], + 'completed_at' => now(), + ]); +} + +it('creates an external ticket from the operation-run support action', function (): void { + spec256ConfigureRunSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + $run = spec256OperationRun($tenant); + + Http::fake([ + 'desk.example.test/*' => Http::response([ + 'ticket_reference' => 'PSA-RUN-256', + ], 201), + ]); + + spec256RunHandoffComponent($user, $run) + ->mountAction('requestSupport') + ->setActionData([ + 'severity' => SupportRequest::SEVERITY_HIGH, + 'summary' => 'Run create external ticket handoff.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + ]) + ->callMountedAction() + ->assertHasNoActionErrors() + ->assertNotified('Support request submitted'); + + $supportRequest = SupportRequest::query()->sole(); + + expect($supportRequest->primary_context_type)->toBe(SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN) + ->and($supportRequest->operation_run_id)->toBe((int) $run->getKey()) + ->and($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET) + ->and($supportRequest->external_ticket_reference)->toBe('PSA-RUN-256') + ->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-RUN-256'); +}); + +it('links an existing external ticket from the operation-run support action without outbound create', function (): void { + spec256ConfigureRunSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager'); + $run = spec256OperationRun($tenant); + + Http::fake(); + + spec256RunHandoffComponent($user, $run) + ->mountAction('requestSupport') + ->setActionData([ + 'summary' => 'Run link existing external ticket.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-RUN-LINK', + ]) + ->callMountedAction() + ->assertHasNoActionErrors(); + + $supportRequest = SupportRequest::query()->sole(); + + expect($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) + ->and($supportRequest->external_ticket_reference)->toBe('PSA-RUN-LINK') + ->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-RUN-LINK'); + + Http::assertNothingSent(); +}); + +it('keeps the internal run support request when external create fails', function (): void { + spec256ConfigureRunSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $run = spec256OperationRun($tenant); + + Http::fake([ + 'desk.example.test/*' => Http::failedConnection(), + ]); + + spec256RunHandoffComponent($user, $run) + ->mountAction('requestSupport') + ->setActionData([ + 'summary' => 'Run external handoff failure should keep internal support request.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + ]) + ->callMountedAction() + ->assertHasNoActionErrors() + ->assertNotified('Support request submitted'); + + $supportRequest = SupportRequest::query()->sole(); + + expect($supportRequest->primary_context_type)->toBe(SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN) + ->and($supportRequest->operation_run_id)->toBe((int) $run->getKey()) + ->and($supportRequest->external_ticket_reference)->toBeNull() + ->and($supportRequest->external_handoff_failure_summary)->toContain('configured timeout') + ->and(OperationRun::query()->count())->toBe(1); +}); diff --git a/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php b/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php new file mode 100644 index 00000000..50b3ee79 --- /dev/null +++ b/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php @@ -0,0 +1,140 @@ + array_merge([ + 'enabled' => true, + 'name' => 'Spec 256 Desk', + 'create_url' => 'https://desk.example.test/api/tickets', + 'api_token' => null, + 'ticket_url_template' => 'https://desk.example.test/tickets/{reference}', + 'timeout_seconds' => 5, + ], $overrides), + ]); +} + +function spec256AuditTenantComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable +{ + test()->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + setTenantPanelContext($tenant); + + return Livewire::actingAs($user)->test(TenantDashboard::class); +} + +it('preserves support request created audit and records external ticket created audit', function (): void { + spec256ConfigureAuditSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + Http::fake([ + 'desk.example.test/*' => Http::response([ + 'ticket_reference' => 'PSA-AUDIT-CREATED', + 'raw_secret' => 'must-not-be-copied', + ], 201), + ]); + + spec256AuditTenantComponent($user, $tenant) + ->mountAction('requestSupport') + ->setActionData([ + 'summary' => 'Audit external ticket created.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + ]) + ->callMountedAction() + ->assertHasNoActionErrors(); + + $supportRequest = SupportRequest::query()->sole(); + + $createdAudit = AuditLog::query() + ->where('action', AuditActionId::SupportRequestCreated->value) + ->sole(); + + $externalAudit = AuditLog::query() + ->where('action', AuditActionId::SupportRequestExternalTicketCreated->value) + ->sole(); + + expect($createdAudit->resource_id)->toBe((string) $supportRequest->getKey()) + ->and($externalAudit->resource_id)->toBe((string) $supportRequest->getKey()) + ->and($externalAudit->tenant_id)->toBe((int) $tenant->getKey()) + ->and($externalAudit->status)->toBe('success') + ->and(data_get($externalAudit->metadata, 'internal_reference'))->toBe($supportRequest->internal_reference) + ->and(data_get($externalAudit->metadata, 'external_handoff_mode'))->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET) + ->and(data_get($externalAudit->metadata, 'external_ticket_reference'))->toBe('PSA-AUDIT-CREATED') + ->and((string) json_encode($externalAudit->metadata))->not->toContain('must-not-be-copied'); +}); + +it('records external ticket linked audit without issuing outbound create', function (): void { + spec256ConfigureAuditSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + Http::fake(); + + spec256AuditTenantComponent($user, $tenant) + ->mountAction('requestSupport') + ->setActionData([ + 'summary' => 'Audit external ticket linked.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-AUDIT-LINKED', + ]) + ->callMountedAction() + ->assertHasNoActionErrors(); + + $externalAudit = AuditLog::query() + ->where('action', AuditActionId::SupportRequestExternalTicketLinked->value) + ->sole(); + + expect(data_get($externalAudit->metadata, 'external_handoff_mode'))->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) + ->and(data_get($externalAudit->metadata, 'external_ticket_reference'))->toBe('PSA-AUDIT-LINKED') + ->and($externalAudit->status)->toBe('success'); + + Http::assertNothingSent(); +}); + +it('records external handoff failed audit with bounded failure metadata', function (): void { + spec256ConfigureAuditSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager'); + + Http::fake([ + 'desk.example.test/*' => Http::failedConnection(), + ]); + + spec256AuditTenantComponent($user, $tenant) + ->mountAction('requestSupport') + ->setActionData([ + 'summary' => 'Audit external ticket failure.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + ]) + ->callMountedAction() + ->assertHasNoActionErrors(); + + $supportRequest = SupportRequest::query()->sole(); + + $externalAudit = AuditLog::query() + ->where('action', AuditActionId::SupportRequestExternalHandoffFailed->value) + ->sole(); + + expect($externalAudit->resource_id)->toBe((string) $supportRequest->getKey()) + ->and($externalAudit->status)->toBe('failed') + ->and(data_get($externalAudit->metadata, 'external_ticket_reference'))->toBeNull() + ->and(data_get($externalAudit->metadata, 'external_handoff_failure_summary'))->toContain('configured timeout'); +}); diff --git a/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php b/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php new file mode 100644 index 00000000..fe2afd64 --- /dev/null +++ b/apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php @@ -0,0 +1,131 @@ +actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + setTenantPanelContext($tenant); + + return Livewire::actingAs($user)->test(TenantDashboard::class); +} + +function spec256AuthorizationOperationComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable +{ + test()->actingAs($user); + Filament::setTenant(null, true); + session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id); + + return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]); +} + +function spec256AuthorizationRun(Tenant $tenant): OperationRun +{ + return OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'completed_at' => now(), + ]); +} + +it('keeps external handoff actions forbidden for entitled tenant members without support-create capability', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + + spec256AuthorizationTenantComponent($user, $tenant) + ->assertActionVisible('requestSupport') + ->assertActionDisabled('requestSupport') + ->call('authorizeTenantSupportRequest') + ->assertForbidden(); + + expect(SupportRequest::query()->count())->toBe(0); +}); + +it('keeps external handoff actions forbidden for entitled run viewers without support-create capability', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + $run = spec256AuthorizationRun($tenant); + + spec256AuthorizationOperationComponent($user, $run) + ->assertActionVisible('requestSupport') + ->assertActionDisabled('requestSupport') + ->call('authorizeOperationRunSupportRequest') + ->assertForbidden(); + + expect(SupportRequest::query()->count())->toBe(0); +}); + +it('does not reveal latest tenant handoff summaries to workspace members without tenant entitlement', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'role' => 'operator', + ]); + + SupportRequest::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT, + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-HIDDEN', + ]); + + try { + app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user); + $this->fail('Expected latest handoff summary to deny as not found.'); + } catch (HttpExceptionInterface $exception) { + expect($exception->getStatusCode())->toBe(404); + } +}); + +it('does not reveal latest run handoff summaries outside the run tenant entitlement', function (): void { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $run = spec256AuthorizationRun($tenant); + + SupportRequest::factory() + ->forOperationRun($run) + ->create([ + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-RUN-HIDDEN', + ]); + + try { + app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($run, $user); + $this->fail('Expected latest run handoff summary to deny as not found.'); + } catch (HttpExceptionInterface $exception) { + expect($exception->getStatusCode())->toBe(404); + } +}); diff --git a/apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php b/apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php new file mode 100644 index 00000000..3a5b4579 --- /dev/null +++ b/apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php @@ -0,0 +1,187 @@ + array_merge([ + 'enabled' => true, + 'name' => 'Spec 256 Desk', + 'create_url' => 'https://desk.example.test/api/tickets', + 'api_token' => null, + 'ticket_url_template' => 'https://desk.example.test/tickets/{reference}', + 'timeout_seconds' => 5, + ], $overrides), + ]); +} + +function spec256TenantHandoffComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable +{ + test()->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + setTenantPanelContext($tenant); + + return Livewire::actingAs($user)->test(TenantDashboard::class); +} + +it('creates an external ticket from the tenant dashboard support action', function (): void { + spec256ConfigureTenantSupportDesk(); + + $tenant = Tenant::factory()->create(['name' => 'Spec 256 Tenant']); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + Http::fake([ + 'desk.example.test/*' => Http::response([ + 'ticket_reference' => 'PSA-2561', + 'ticket_url' => 'https://desk.example.test/tickets/PSA-2561', + ], 201), + ]); + + spec256TenantHandoffComponent($user, $tenant) + ->mountAction('requestSupport') + ->setActionData([ + 'severity' => SupportRequest::SEVERITY_HIGH, + 'summary' => 'Tenant create external ticket handoff.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + ]) + ->callMountedAction() + ->assertHasNoActionErrors() + ->assertNotified('Support request submitted'); + + $supportRequest = SupportRequest::query()->sole(); + + expect($supportRequest->internal_reference)->toMatch('/^SR-[0-9A-HJKMNP-TV-Z]{26}$/') + ->and($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET) + ->and($supportRequest->external_ticket_reference)->toBe('PSA-2561') + ->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-2561') + ->and($supportRequest->external_handoff_failure_summary)->toBeNull() + ->and($supportRequest->externalHandoffOutcome())->toBe(SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://desk.example.test/api/tickets' + && data_get($request->data(), 'support_request.internal_reference') === $supportRequest->internal_reference); +}); + +it('links an existing external ticket from the tenant dashboard without creating a duplicate external ticket', function (): void { + spec256ConfigureTenantSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + Http::fake(); + + spec256TenantHandoffComponent($user, $tenant) + ->mountAction('requestSupport') + ->setActionData([ + 'severity' => SupportRequest::SEVERITY_NORMAL, + 'summary' => 'Tenant link existing external ticket.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-256-LINK', + 'external_ticket_url' => 'https://desk.example.test/tickets/PSA-256-LINK', + ]) + ->callMountedAction() + ->assertHasNoActionErrors() + ->assertNotified('Support request submitted'); + + $supportRequest = SupportRequest::query()->sole(); + + expect($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) + ->and($supportRequest->external_ticket_reference)->toBe('PSA-256-LINK') + ->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-256-LINK') + ->and($supportRequest->externalHandoffOutcome())->toBe(SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED); + + Http::assertNothingSent(); +}); + +it('rejects invalid linked external ticket input before storing a tenant support request', function (): void { + spec256ConfigureTenantSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + Http::fake(); + + spec256TenantHandoffComponent($user, $tenant) + ->mountAction('requestSupport') + ->setActionData([ + 'severity' => SupportRequest::SEVERITY_NORMAL, + 'summary' => 'Tenant invalid link should not create support truth.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'not a ticket', + ]) + ->callMountedAction() + ->assertHasErrors(['external_ticket_reference']); + + expect(SupportRequest::query()->count())->toBe(0); + + Http::assertNothingSent(); +}); + +it('keeps the internal tenant support request when external create fails', function (): void { + spec256ConfigureTenantSupportDesk(); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager'); + + Http::fake([ + 'desk.example.test/*' => Http::failedConnection(), + ]); + + spec256TenantHandoffComponent($user, $tenant) + ->mountAction('requestSupport') + ->setActionData([ + 'severity' => SupportRequest::SEVERITY_BLOCKING, + 'summary' => 'Tenant external desk timeout should keep internal support request.', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + ]) + ->callMountedAction() + ->assertHasNoActionErrors() + ->assertNotified('Support request submitted'); + + $supportRequest = SupportRequest::query()->sole(); + + expect($supportRequest->internal_reference)->toMatch('/^SR-/') + ->and($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET) + ->and($supportRequest->external_ticket_reference)->toBeNull() + ->and($supportRequest->external_ticket_url)->toBeNull() + ->and($supportRequest->external_handoff_failure_summary)->toContain('configured timeout') + ->and($supportRequest->externalHandoffOutcome())->toBe(SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED); +}); + +it('forces tenant support requests to internal only when no external target is configured', function (): void { + spec256ConfigureTenantSupportDesk([ + 'enabled' => false, + ]); + + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + Http::fake(); + + spec256TenantHandoffComponent($user, $tenant) + ->mountAction('requestSupport') + ->setActionData([ + 'summary' => 'Tenant support stays internal when no support desk target exists.', + ]) + ->callMountedAction() + ->assertHasNoActionErrors(); + + $supportRequest = SupportRequest::query()->sole(); + + expect($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY) + ->and($supportRequest->external_ticket_reference)->toBeNull() + ->and($supportRequest->external_handoff_failure_summary)->toBeNull(); + + Http::assertNothingSent(); +}); diff --git a/apps/platform/tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php b/apps/platform/tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php new file mode 100644 index 00000000..3836287f --- /dev/null +++ b/apps/platform/tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php @@ -0,0 +1,121 @@ + array_merge([ + 'enabled' => true, + 'name' => 'Spec 256 Desk', + 'create_url' => 'https://desk.example.test/api/tickets', + 'api_token' => null, + 'ticket_url_template' => 'https://desk.example.test/tickets/{reference}', + 'timeout_seconds' => 5, + ], $overrides), + ]); +} + +function spec256SupportRequest(array $attributes = []): SupportRequest +{ + $tenant = Tenant::factory()->create(); + + return SupportRequest::factory()->create(array_merge([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'summary' => 'Need external support desk handoff.', + 'severity' => SupportRequest::SEVERITY_HIGH, + ], $attributes)); +} + +it('creates an external ticket through the configured target and normalizes the returned reference', function (): void { + configureSpec256SupportDesk([ + 'api_token' => 'secret-token', + ]); + + $supportRequest = spec256SupportRequest(); + + Http::fake([ + 'desk.example.test/*' => Http::response([ + 'ticket_reference' => 'PSA-12345', + 'ticket_url' => 'https://desk.example.test/tickets/PSA-12345', + ], 201), + ]); + + $result = app(ExternalSupportDeskHandoffService::class)->createTicket($supportRequest); + + expect($result['successful'])->toBeTrue() + ->and($result['external_ticket_reference'])->toBe('PSA-12345') + ->and($result['external_ticket_url'])->toBe('https://desk.example.test/tickets/PSA-12345') + ->and($result['failure_summary'])->toBeNull(); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://desk.example.test/api/tickets' + && data_get($request->data(), 'support_request.internal_reference') === $supportRequest->internal_reference + && data_get($request->data(), 'support_request.summary') === 'Need external support desk handoff.'); +}); + +it('enforces the five second timeout budget and normalizes connection failures', function (): void { + configureSpec256SupportDesk([ + 'timeout_seconds' => 30, + ]); + + $supportRequest = spec256SupportRequest(); + + Http::fake([ + 'desk.example.test/*' => Http::failedConnection(), + ]); + + $service = app(ExternalSupportDeskHandoffService::class); + $result = $service->createTicket($supportRequest); + + expect($service->timeoutSeconds())->toBe(5) + ->and($result['successful'])->toBeFalse() + ->and($result['external_ticket_reference'])->toBeNull() + ->and($result['failure_summary'])->toContain('configured timeout'); +}); + +it('falls back to unavailable when the single configured target is disabled', function (): void { + configureSpec256SupportDesk([ + 'enabled' => false, + ]); + + Http::fake(); + + $service = app(ExternalSupportDeskHandoffService::class); + $result = $service->createTicket(spec256SupportRequest()); + + expect($service->targetIsConfigured())->toBeFalse() + ->and($result['successful'])->toBeFalse() + ->and($result['failure_summary'])->toBe('External support desk target is not configured.'); + + Http::assertNothingSent(); +}); + +it('normalizes linked tickets without issuing an outbound create call', function (): void { + configureSpec256SupportDesk(); + Http::fake(); + + $result = app(ExternalSupportDeskHandoffService::class)->normalizeLinkedTicket(' PSA-900 ', null); + + expect($result['external_ticket_reference'])->toBe('PSA-900') + ->and($result['external_ticket_url'])->toBe('https://desk.example.test/tickets/PSA-900'); + + Http::assertNothingSent(); +}); + +it('rejects invalid linked ticket input before storing external truth', function (): void { + configureSpec256SupportDesk(); + + expect(fn (): array => app(ExternalSupportDeskHandoffService::class)->normalizeLinkedTicket('not a ticket', 'javascript:alert(1)')) + ->toThrow(ValidationException::class); +}); diff --git a/apps/platform/tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php b/apps/platform/tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php new file mode 100644 index 00000000..df3fed6f --- /dev/null +++ b/apps/platform/tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php @@ -0,0 +1,113 @@ +create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + SupportRequest::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT, + 'internal_reference' => 'SR-OLDTENANT0000000000000001', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-OLD', + 'created_at' => now()->subMinutes(10), + ]); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'created_at' => now(), + ]); + + SupportRequest::factory() + ->forOperationRun($run) + ->create([ + 'internal_reference' => 'SR-RUN000000000000000000001', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-RUN', + 'created_at' => now(), + ]); + + SupportRequest::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT, + 'internal_reference' => 'SR-NEWTENANT0000000000000001', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET, + 'external_handoff_failure_summary' => 'External support desk did not respond before the configured timeout.', + 'created_at' => now()->subMinute(), + ]); + + $summary = app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user); + + expect($summary)->not->toBeNull() + ->and($summary['internal_reference'])->toBe('SR-NEWTENANT0000000000000001') + ->and($summary['has_failure'])->toBeTrue() + ->and($summary['has_external_link'])->toBeFalse() + ->and($summary['external_handoff_failure_summary'])->toContain('configured timeout'); +}); + +it('returns the latest run-scoped handoff summary for the current run only', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator'); + + $firstRun = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + ]); + + $secondRun = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + ]); + + SupportRequest::factory() + ->forOperationRun($secondRun) + ->create([ + 'internal_reference' => 'SR-OTHERRUN0000000000000001', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-OTHER', + 'created_at' => now(), + ]); + + SupportRequest::factory() + ->forOperationRun($firstRun) + ->create([ + 'internal_reference' => 'SR-CURRENTRUN0000000000001', + 'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET, + 'external_ticket_reference' => 'PSA-CURRENT', + 'external_ticket_url' => 'https://desk.example.test/tickets/PSA-CURRENT', + 'created_at' => now()->subMinute(), + ]); + + $summary = app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($firstRun, $user); + + expect($summary)->not->toBeNull() + ->and($summary['internal_reference'])->toBe('SR-CURRENTRUN0000000000001') + ->and($summary['external_ticket_reference'])->toBe('PSA-CURRENT') + ->and($summary['external_ticket_url'])->toBe('https://desk.example.test/tickets/PSA-CURRENT') + ->and($summary['has_external_link'])->toBeTrue(); +}); diff --git a/specs/256-external-support-desk-handoff/checklists/requirements.md b/specs/256-external-support-desk-handoff/checklists/requirements.md new file mode 100644 index 00000000..a1142c29 --- /dev/null +++ b/specs/256-external-support-desk-handoff/checklists/requirements.md @@ -0,0 +1,63 @@ +# Preparation Review Checklist: External Support Desk / PSA Handoff + +**Purpose**: Validate the prepared support-handoff package against the repo's guardrail, support-truth, provider-boundary, scoped-visibility, and close-out workflow requirements before implementation +**Created**: 2026-04-29 +**Feature**: [spec.md](../spec.md) + +## Applicability And Low-Impact Gate + +- [x] CHK001 The package explicitly treats this as an operator-facing extension on two existing support-aware actions, so the low-impact `N/A` path is not used. +- [x] CHK002 The spec, plan, tasks, and supporting artifacts carry the same bounded slice: existing `SupportRequest` truth stays authoritative, visibility stays on the current tenant or run support contexts only, handoff remains one-way, one configured target is allowed, and the close-out target remains `Guardrail / Exception / Smoke Coverage`. + +## Native, Shared-Family, And State Ownership + +- [x] CHK003 The primary surfaces remain native Filament actions on `TenantDashboard` and `TenantlessOperationRunViewer` instead of a support-request resource, support queue, helpdesk shell, or standalone external-desk page. +- [x] CHK004 Shared support families remain shared: the internal `SR-...` support request stays the canonical truth, the latest handoff summary stays attached to the same two support actions, and the package does not invent a parallel support history or ticket-register surface. +- [x] CHK005 Page, detail, action-form, and persisted state owners are named once: `support_requests` is the only planned persisted truth, while the tenant and run pages own only current-context presentation and submit-time form state. +- [x] CHK006 The likely next operator action and primary inspect/open model stay coherent: the operator uses the existing `Request support` action, chooses one handoff mode inside that form, and does not branch into a second workflow family. + +## Shared Pattern Reuse + +- [x] CHK007 Cross-cutting interaction classes are explicit, and the shared reuse path is named once through `SupportRequestSubmissionService`, `SupportRequestContextBuilder`, `SupportRequestReferenceGenerator`, `WorkspaceAuditLogger`, `UiEnforcement`, and the existing support-aware action surfaces. +- [x] CHK008 The package extends existing shared paths where they are sufficient, and the only allowed deviation is one concrete provider-owned handoff service plus one tiny latest-summary read helper if implementation proves it necessary, not a generic helpdesk registry or page-local HTTP path. +- [x] CHK009 The package does not create a parallel operator UX language; `Request support`, `Support reference`, `External ticket`, `Create external ticket`, `Link existing ticket`, and `TenantPilot only` versus `TenantPilot + external support desk` stay consistent across tenant, run, notification, and audit wording. + +## OperationRun Start UX Contract + +- [x] CHK019 The package explicitly states that the run surface uses the current `OperationRun` only as support context and does not create, queue, deduplicate, resume, block, complete, or deep-link to a run workflow as part of the handoff slice. +- [x] CHK020 Run-specific workflow contracts stay on the existing canonical run page; queued toast/link/browser-event/dedupe behavior is not reintroduced locally for support handoff. +- [x] CHK021 No queued DB notification or terminal-notification path is added because the slice stays synchronous inside the current support-request submit path. +- [x] CHK022 No `OperationRun` exception is required in the preparation package; if implementation later adds retries, queueing, or run-orchestration semantics, that must be recorded as out-of-scope drift in the active close-out entry. + +## Provider Boundary And Vocabulary + +- [x] CHK010 Provider-specific semantics stay behind one concrete provider-owned handoff service and one preconfigured target-resolution seam; the planned persisted truth stays neutral on `SupportRequest` with handoff mode, external reference, external URL, and bounded failure summary only. +- [x] CHK011 No retained provider-specific shared boundary or second-target abstraction is introduced; multi-provider support, target-management UI, and broader ITSM or helpdesk modeling remain follow-up-spec work only. + +## Signals, Exceptions, And Test Depth + +- [x] CHK012 The triggered repository signal is explicitly handled as `review-mandatory`: the package adds a new provider seam and new persisted fields, but it does so on the existing support-request truth without hidden queue, resource, or support-framework drift. +- [x] CHK013 One bounded contract exception is explicit in the preparation package: Spec 256 allows exactly one synchronous finalization write on the same `SupportRequest` row after internal creation, limited to external handoff fields only. Any wider mutability, retry orchestration, or support-history spread must still be documented in the active feature close-out entry instead of becoming silent scope growth. +- [x] CHK014 The required surface test profile is explicit: `standard-native-filament` for the tenant dashboard action, `monitoring-state-page` for the run action, and focused `Unit` plus `Feature` proof for handoff branching, scoped summary reuse, authorization, and audit behavior. +- [x] CHK015 The chosen lane mix is the narrowest honest proof for this slice: focused Pest unit plus feature coverage with narrow manual smoke after implementation, and no implicit browser-only, global-search, or new resource coverage obligation is invented. + +## Audience-Aware Disclosure And Decision Hierarchy + +- [x] CHK023 Default-visible content stays decision-first: current support context, one handoff-mode decision, one latest bounded handoff summary, and one submit action remain primary. +- [x] CHK024 The package keeps raw/provider-heavy material out of default-visible truth: no raw payloads, credentials, provider response bodies, assignee or SLA fields, retry status, or cross-scope lookup shortcuts are allowed into the support-request row or default UI copy. +- [x] CHK025 Exactly one dominant next action remains primary: `Submit support request`; external create or link is modeled as a form choice, not as a competing primary action or second workflow entry point. +- [x] CHK026 Duplicate visible truth is avoided by naming one internal support reference and one latest context-scoped handoff summary instead of introducing a ticket history block, queue summary, or separate support register surface. +- [x] CHK027 Support or raw detail stays hidden or provider-owned, and latest handoff visibility remains bounded to the same entitled tenant or current run context with the existing `404` versus `403` rules. + +## Review Outcome + +- [x] CHK016 Review outcome class: `acceptable-special-case` +- [x] CHK017 Workflow outcome: `keep` +- [x] CHK018 The final note location is explicit: the active feature PR close-out entry `Guardrail / Exception / Smoke Coverage` should record target-prerequisite status, any bounded implementation exception, and the final proof or smoke outcome. + +## Notes + +- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, `research.md`, `data-model.md`, `quickstart.md`, and the conceptual contract. It does not claim application code exists. +- The slice remains bounded to the existing support-request truth and the two existing support-aware actions only. No support-request resource, support queue, helpdesk framework, global-search surface, or `OperationRun` workflow is approved by this package. +- Preparation note: the package now makes the single-target resolution seam explicit through `apps/platform/config/support_desk.php` and keeps workspace settings UI, per-workspace target management, second-target support, and retry or relink orchestration as later follow-up scope. +- Preparation note: Spec 256 explicitly narrows Spec 246 immutability for one synchronous handoff-finalization write only; no broader edit, reopen, merge, or lifecycle workflow is approved by this package. \ No newline at end of file diff --git a/specs/256-external-support-desk-handoff/contracts/external-support-desk-handoff.logical.openapi.yaml b/specs/256-external-support-desk-handoff/contracts/external-support-desk-handoff.logical.openapi.yaml new file mode 100644 index 00000000..972a00c0 --- /dev/null +++ b/specs/256-external-support-desk-handoff/contracts/external-support-desk-handoff.logical.openapi.yaml @@ -0,0 +1,216 @@ +openapi: 3.0.3 +info: + title: TenantPilot Admin — External Support Desk Handoff (Conceptual) + version: 0.1.0 + description: | + Conceptual contract for the first external support desk handoff slice. + + NOTE: These flows are implemented as Filament (Livewire) actions on + existing pages. This file captures the expected action payload, outcome + semantics, and authorization boundaries rather than a public REST API. +servers: + - url: /admin +paths: + /t/{tenant}/support-requests/actions/submit: + post: + summary: Submit a tenant-context support request with optional external handoff + description: | + Existing tenant dashboard support action, extended with one-way external + handoff behavior. + + Authorization: + - Workspace non-member or non-entitled tenant actor: 404 + - Entitled tenant member without `support_requests.create`: 403 + - Authorized actor: 200 with one support-request submission result + + Behavior: + - Always creates the internal `SR-...` support request first + - `internal_only` performs no outbound handoff + - `link_existing_ticket` stores the provided external reference and must not call external create + - `create_external_ticket` uses one application-configured external target only + - `create_external_ticket` applies a maximum 5 second outbound timeout budget + - External create failure keeps the internal support request and returns an explicit failed-handoff outcome + - No queue, `OperationRun`, retry scheduler, or bidirectional sync is introduced + parameters: + - name: tenant + in: path + required: true + schema: + type: string + description: Filament tenancy slug (`tenants.external_id`) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SupportRequestHandoffSubmission' + responses: + '200': + description: Support request accepted with internal-only, linked, created, or failed-handoff outcome + content: + application/json: + schema: + $ref: '#/components/schemas/SupportRequestHandoffResult' + '403': + description: Forbidden (entitled tenant member lacks support-request capability) + '404': + description: Not found (wrong workspace, non-member, or missing tenant entitlement) + /operations/{run}/support-requests/actions/submit: + post: + summary: Submit a run-context support request with optional external handoff + description: | + Existing canonical run detail support action, extended with one-way + external handoff behavior. + + Authorization: + - Inaccessible run or run outside entitled tenant scope: 404 + - Entitled member without `support_requests.create`: 403 + - Authorized actor: 200 with one support-request submission result + + Behavior: + - The run must resolve to an entitled tenant before any support truth is revealed + - Uses the same payload contract and outcome semantics as the tenant-context action + - Does not create, resume, or update an `OperationRun` + parameters: + - name: run + in: path + required: true + schema: + type: integer + description: Internal `operation_runs.id` + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SupportRequestHandoffSubmission' + responses: + '200': + description: Support request accepted with internal-only, linked, created, or failed-handoff outcome + content: + application/json: + schema: + $ref: '#/components/schemas/SupportRequestHandoffResult' + '403': + description: Forbidden (entitled member lacks support-request capability) + '404': + description: Not found (run inaccessible under workspace or tenant scope) +components: + schemas: + SupportRequestHandoffSubmission: + type: object + required: + - severity + - summary + - handoff_mode + properties: + severity: + type: string + enum: [low, normal, high, blocking] + summary: + type: string + reproduction_notes: + type: string + nullable: true + contact_name: + type: string + nullable: true + contact_email: + type: string + format: email + nullable: true + handoff_mode: + type: string + enum: [internal_only, create_external_ticket, link_existing_ticket] + external_ticket_reference: + type: string + nullable: true + description: Required when `handoff_mode = link_existing_ticket` + external_ticket_url: + type: string + format: uri + nullable: true + target_available: + type: boolean + nullable: true + description: Derived UI hint only; the server remains authoritative + SupportRequestHandoffResult: + type: object + required: + - support_request_id + - internal_reference + - primary_context_type + - handoff_mode + - handoff_outcome + - latest_summary + properties: + support_request_id: + type: integer + internal_reference: + type: string + primary_context_type: + type: string + enum: [tenant, operation_run] + primary_context_id: + type: integer + nullable: true + handoff_mode: + type: string + enum: [internal_only, create_external_ticket, link_existing_ticket] + handoff_outcome: + type: string + enum: + - internal_only + - external_ticket_created + - external_ticket_linked + - external_handoff_failed + external_ticket_reference: + type: string + nullable: true + external_ticket_url: + type: string + format: uri + nullable: true + failure_summary: + type: string + nullable: true + latest_summary: + $ref: '#/components/schemas/LatestSupportRequestHandoffSummary' + LatestSupportRequestHandoffSummary: + type: object + required: + - internal_reference + - primary_context_type + - submitted_at + - handoff_mode + - has_external_link + - has_failure + properties: + internal_reference: + type: string + primary_context_type: + type: string + enum: [tenant, operation_run] + primary_context_id: + type: integer + nullable: true + submitted_at: + type: string + format: date-time + handoff_mode: + type: string + enum: [internal_only, create_external_ticket, link_existing_ticket] + external_ticket_reference: + type: string + nullable: true + external_ticket_url: + type: string + format: uri + nullable: true + external_handoff_failure_summary: + type: string + nullable: true + has_external_link: + type: boolean + has_failure: + type: boolean \ No newline at end of file diff --git a/specs/256-external-support-desk-handoff/data-model.md b/specs/256-external-support-desk-handoff/data-model.md new file mode 100644 index 00000000..b4fc3f95 --- /dev/null +++ b/specs/256-external-support-desk-handoff/data-model.md @@ -0,0 +1,161 @@ +# Data Model — External Support Desk / PSA Handoff + +**Spec**: [spec.md](spec.md) + +Spec 256 extends the existing support-request truth. No new support-ticket table, resource, or queue artifact is introduced. + +## Existing Canonical Entity Extended + +### SupportRequest (`support_requests`) + +**Purpose**: Canonical tenant-owned support-request truth. Spec 256 extends it so the same row can carry one-way external handoff continuity. + +**Existing key fields (already in repo)**: +- `id` +- `workspace_id` +- `tenant_id` +- `operation_run_id` +- `initiated_by_user_id` +- `internal_reference` +- `primary_context_type` +- `attachment_mode` +- `severity` +- `summary` +- `reproduction_notes` +- `contact_name` +- `contact_email` +- `context_envelope` +- `created_at` +- `updated_at` + +**New fields (planned)**: +- `external_handoff_mode` + - type: string + - required: yes + - default: `internal_only` + - allowed values: + - `internal_only` + - `create_external_ticket` + - `link_existing_ticket` +- `external_ticket_reference` + - type: nullable string + - stored when an external ticket was created or linked successfully +- `external_ticket_url` + - type: nullable text + - stored only when the target returns or the operator provides a valid URL +- `external_handoff_failure_summary` + - type: nullable text + - bounded human-readable failure summary for the current request only + +**Relationships (unchanged)**: +- belongs to `Workspace` +- belongs to `Tenant` +- optionally belongs to `OperationRun` +- optionally belongs to initiator `User` + +**Behavioral rules**: +- `internal_reference` remains the canonical TenantPilot support identifier even when an external ticket exists. +- `external_handoff_mode` records the operator’s chosen path and replaces the need for a second persisted status family. +- Spec 256 explicitly narrows Spec 246 immutability in one bounded way: after the internal request is created, the same row may receive exactly one synchronous finalization write limited to `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary`. No later edit, reopen, merge, or status workflow is introduced. +- `external_ticket_reference` and `external_ticket_url` remain null for `internal_only` and for failed create attempts. +- `external_handoff_failure_summary` remains null on successful create, successful link, and internal-only submissions. +- On a failed external create, the row persists with: + - `external_handoff_mode = create_external_ticket` + - `external_ticket_reference = null` + - `external_ticket_url = null` + - `external_handoff_failure_summary` populated +- When the failed external create was caused by timeout, `external_handoff_failure_summary` stores the same bounded timeout-oriented message that the UI and audit path use. Raw transport detail is never persisted. + +**Latest-summary query rules**: +- Tenant dashboard summary queries the latest support request for the current entitled tenant where `primary_context_type = tenant`. +- Operation-run summary queries the latest support request for the current run where `primary_context_type = operation_run` and `operation_run_id` matches the viewed run. +- Existing indexes on `(tenant_id, created_at)` and `(operation_run_id, created_at)` are sufficient. No new lookup path by external reference is planned. + +**Validation rules**: +- `external_handoff_mode` must be one of the three allowed values. +- `external_ticket_reference` is required when `external_handoff_mode = link_existing_ticket`. +- `external_ticket_url` is optional but must be a valid URL when present. +- When no external target is configured for the application, the form must force or constrain the effective mode to `internal_only`. + +## Application-Configured External Target (Config Contract In Scope, Not New Persisted Truth) + +### External Support Desk Target + +**Purpose**: Supplies the one configured outbound target for create or link normalization. + +**Status in Spec 256**: +- minimal application config contract in scope +- not a new persisted entity in this slice +- not a workspace settings domain or UI surface in this slice + +**Repo-grounded note**: +- The repo has no existing `support` settings domain, so Spec 256 makes the target seam explicit through one application config file: `apps/platform/config/support_desk.php` with environment-backed values for the single supported target. +- This config contract may define availability, create endpoint settings, reference-link normalization defaults, and the five-second outbound timeout budget. +- Per-workspace target selection, settings UI, or a second target remain follow-up scope. + +## Derived Runtime Entities + +### SupportRequestHandoffOutcome (computed, not persisted) + +**Purpose**: Gives the Filament page actions one normalized outcome for notification copy and tests after submission completes. + +**Expected shape**: +- `support_request_id` +- `internal_reference` +- `primary_context_type` +- `handoff_mode` +- `handoff_outcome` + - `internal_only` + - `external_ticket_created` + - `external_ticket_linked` + - `external_handoff_failed` +- `external_ticket_reference` +- `external_ticket_url` +- `failure_summary` + +**Why derived only**: +- The outcome is an execution summary for one request cycle. +- Persisting it separately would duplicate the support-request truth and audit log. +- The bounded synchronous finalization write on `SupportRequest` remains the only allowed post-create mutation for this slice. + +### LatestSupportRequestHandoffSummary (computed, not persisted) + +**Purpose**: Supplies the existing tenant and run support actions with one scoped summary of the latest linkage for the current primary context. + +**Expected shape**: +- `internal_reference` +- `primary_context_type` +- `primary_context_id` +- `submitted_at` +- `external_handoff_mode` +- `external_ticket_reference` +- `external_ticket_url` +- `external_handoff_failure_summary` +- `has_external_link` +- `has_failure` + +**Why derived only**: +- It is a read model over the latest `support_requests` row for one context. +- A separate table or persisted summary would violate `PERSIST-001` without solving a distinct lifecycle problem. + +## Audit Events (Persistent Audit Truth, Not Product Truth) + +The implementation should add these stable audit actions in addition to the existing `support_request.created` event: + +- `support_request.external_ticket_created` +- `support_request.external_ticket_linked` +- `support_request.external_handoff_failed` + +**Audit context should include**: +- `workspace_id` +- `tenant_id` +- `internal_reference` +- `primary_context_type` +- `primary_context_id` +- `external_handoff_mode` +- `external_ticket_reference` when present + +**Audit context should not include**: +- raw provider request payloads +- secrets or credentials +- unrestricted provider response bodies \ No newline at end of file diff --git a/specs/256-external-support-desk-handoff/plan.md b/specs/256-external-support-desk-handoff/plan.md new file mode 100644 index 00000000..b8267627 --- /dev/null +++ b/specs/256-external-support-desk-handoff/plan.md @@ -0,0 +1,319 @@ +# Implementation Plan: External Support Desk / PSA Handoff + +**Branch**: `256-external-support-desk-handoff` | **Date**: 2026-04-29 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/spec.md` + +## Summary + +- Extend the existing support-request submission flow so the two current support-aware surfaces can keep a request internal-only, create one external desk ticket, or link one existing external ticket without adding a new support product surface. +- Persist only the minimal neutral linkage truth on the existing `support_requests` row: `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary`. +- Keep the flow synchronous and auditable inside the existing support-request path: create the internal `SR-...` request first, allow exactly one bounded synchronous finalization write for external create, link, or failure fields on the same row, enforce a five-second outbound timeout on the create path, and surface the latest linkage summary only in the current tenant or run support context. + +## Technical Context + +**Language/Version**: PHP 8.4 on Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `SupportRequestSubmissionService`, `SupportRequestContextBuilder`, `SupportRequestReferenceGenerator`, `WorkspaceAuditLogger`, `UiEnforcement`, and `CapabilityResolver` +**Storage**: PostgreSQL; extend the tenant-owned `support_requests` table, keep `workspace_id` and `tenant_id` required, and do not add a second support-ticket table +**Testing**: Pest unit + feature tests +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Sail-backed Laravel admin panel under `/admin` and `/admin/t/{tenant}` +**Project Type**: web +**Performance Goals**: keep the submit path synchronous, apply a maximum five-second outbound timeout on the create path, and avoid queue or `OperationRun` overhead +**Constraints**: one application-configured external target only, no new support-request resource/list/detail page, no global-search surface, no bidirectional sync, no retry scheduler, no raw provider payload persistence, no provider registration changes, and no runtime asset changes +**Scale/Scope**: one additive migration on `support_requests`, one concrete provider-owned handoff service, one small derived latest-summary helper or equivalent shared read path, two Filament action-form extensions, audit additions, and focused unit plus feature coverage only + +## Key Design Decisions + +### Persistence and source of truth + +- `support_requests` is the only persisted truth for this slice. No `support_tickets` table, no queue artifact, and no new support page model is justified. +- The plan adds these columns to `support_requests`: + - `external_handoff_mode` as a non-null string with default `internal_only` + - `external_ticket_reference` as a nullable string + - `external_ticket_url` as a nullable text field + - `external_handoff_failure_summary` as a nullable text field +- The plan does not add `external_handoff_status`, `external_target_type`, `external_target_id`, raw payload JSON, or a dedicated failure timestamp. Those are not needed for the current operator contract because: + - the handoff mode already captures operator intent + - success is derivable from `external_ticket_reference` + - failure visibility only needs a bounded summary on revisit + - audit timestamps already provide exact event timing +- Spec 256 explicitly narrows Spec 246 immutability in one bounded way: after the internal request row exists, the same row may receive exactly one synchronous finalization write limited to the external handoff fields above. After that finalization step, the row is immutable again. +- Existing `support_requests` indexes on `(tenant_id, created_at)` and `(operation_run_id, created_at)` are sufficient for latest-summary lookups. No external-reference index is planned because cross-scope lookup by external ticket reference is explicitly out of scope. + +### Failure truth and auditable outcomes + +- External create failure is not audit-only. A bounded failure summary must be persisted back on the same `support_requests` row so the current support context can show the last failure on revisit. +- Timeout is treated as the same failure family as any other create failure. The provider-owned service must enforce the five-second outbound timeout budget and return a normalized bounded failure summary rather than raw transport details. +- Detailed provider-specific error payloads remain out of persisted product truth. They stay confined to the provider-owned handoff service, log redaction rules, and audit metadata where appropriate. +- The internal support request remains durable even when external create fails. The implementation must therefore split the flow into: + 1. authorize and validate the existing request + 2. persist the internal support request and `support_request.created` audit event + 3. perform link or create handoff work after the internal row exists + 4. perform the one allowed synchronous finalization write back to the same row and emit the corresponding audit event + +### Visible linkage stays inside existing support contexts only + +- External ticket references do not become a new dashboard card, run section, support history block, global search result, or Filament resource. +- The narrowest correct visibility path is: + - success or partial-success notification immediately after submit + - a latest-handoff summary placeholder inside the existing `Request support` slide-over on `TenantDashboard` + - the same latest-handoff summary placeholder inside the grouped `Request support` slide-over on `TenantlessOperationRunViewer` +- Tenant context summary scopes to the latest support request where `primary_context_type = tenant` for the current entitled tenant. +- Run context summary scopes to the latest support request where `primary_context_type = operation_run` and `operation_run_id` matches the currently opened run. + +### Minimal application config contract is in scope; support settings UI is not + +- The repo has no existing `support` settings domain, so leaving target resolution as an external prerequisite would create hidden implementation drift. +- This plan therefore brings one minimal application config contract into scope: `apps/platform/config/support_desk.php` backed by environment values for the single supported target. +- The implementation may resolve availability, create endpoint configuration, and timeout settings from that config file only. +- This spec still forbids workspace settings UI, a new settings domain, per-workspace target management, provider-connection product work, or multi-target support. + +### Timeout and latency rule + +- The one application-configured create path must use a maximum five-second outbound timeout. +- A timeout is normalized into the same bounded failure-summary and audit path as any other external create failure. +- The timeout budget is part of the feature contract and must be covered by the handoff-service unit tests. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament actions plus shared support primitives +- **Shared-family relevance**: header actions, grouped detail actions, support-request slide-overs, success or warning notifications, latest-handoff summaries, and external-link navigation +- **State layers in scope**: page, detail, action form +- **Audience modes in scope**: operator-MSP, support-platform +- **Decision/diagnostic/raw hierarchy plan**: decision-first support form, diagnostics-second through the existing neighboring diagnostics action, provider/raw evidence third and hidden +- **Raw/support gating plan**: provider-specific payloads, secrets, and raw responses stay provider-owned and hidden; only bounded human-readable linkage or failure summary becomes default-visible +- **One-primary-action / duplicate-truth control**: the dominant action remains `Submit support request`; handoff choice is a form field, not a second primary action, and the visible summary names one internal support reference so the surface does not become a history register +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: standard-native-filament, monitoring-state-page +- **Required tests or manual smoke**: functional-core, state-contract, manual smoke after implementation +- **Exception path and spread control**: the tenant dashboard keeps its existing bounded action-surface exception; the run viewer keeps both support actions grouped under `More` and does not add a new top-level support action family +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: + - `apps/platform/app/Filament/Pages/TenantDashboard.php` + - `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` + - `apps/platform/app/Models/SupportRequest.php` + - `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` + - `apps/platform/app/Support/SupportRequests/SupportRequestContextBuilder.php` + - `apps/platform/app/Support/SupportRequests/SupportRequestReferenceGenerator.php` + - `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` + - `apps/platform/app/Support/Audit/AuditActionId.php` + - `apps/platform/database/factories/SupportRequestFactory.php` + - `apps/platform/config/support_desk.php` + - `apps/platform/lang/en/localization.php` + - `apps/platform/lang/de/localization.php` +- **Shared abstractions reused**: existing support-request submission path, existing redacted context builder, existing internal reference generator, existing audit logger, and existing `UiEnforcement` capability gating +- **New abstraction introduced? why?**: one concrete provider-owned external handoff service is justified because both existing surfaces must call or normalize one real external target without page-local HTTP logic; one tiny shared latest-summary read helper is allowed if needed to avoid duplicating the same context-scoped query and copy twice +- **Why the existing abstraction was sufficient or insufficient**: the existing abstractions already solve context capture, internal request creation, and audit logging, but they stop at internal persistence and cannot yet persist external linkage or explicit handoff failure truth +- **Bounded deviation / spread control**: no interface registry, no adapter catalog, no support-desk framework, no second persistence model, and no new support history vocabulary + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no +- **Central contract reused**: N/A +- **Delegated UX behaviors**: N/A +- **Surface-owned behavior kept local**: the run viewer uses the current run only as support context and as the scoping key for its latest-handoff summary; it does not create, resume, or link an `OperationRun` +- **Queued DB-notification policy**: N/A +- **Terminal notification path**: N/A +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes +- **Provider-owned seams**: outbound create payload, authentication, target-specific reference normalization, URL normalization, and remote error parsing +- **Platform-core seams**: `SupportRequest`, internal support reference, external ticket reference and URL, handoff mode, latest-handoff summary, and bounded failure summary +- **Neutral platform terms / contracts preserved**: `Request support`, `Support reference`, `External ticket`, `Create external ticket`, `Link existing ticket`, and `TenantPilot only` versus `TenantPilot + external support desk` +- **Retained provider-specific semantics and why**: provider-specific ticket identifiers, auth requirements, and request payload shape remain inside one concrete provider-owned service because the current release has exactly one real external target +- **Bounded extraction or follow-up path**: multi-provider support, target-management UI, and broader ITSM modeling remain follow-up-spec work only + +## Constitution Check + +*GATE: Passed against repo truth before artifact write. Re-checked after Phase 1 design artifacts were drafted.* + +- Inventory-first / snapshots-second: PASS. The slice does not alter inventory or snapshot truth. +- Read/write separation: PASS. The mutation remains an explicit operator submit action with auditable outcomes and planned tests. +- Graph contract path: PASS. No Microsoft Graph calls are introduced. +- Deterministic capabilities: PASS. Capability checks stay on `Capabilities::SUPPORT_REQUESTS_CREATE`; no raw capability strings or role-string checks are planned. +- RBAC-UX / workspace isolation / tenant isolation: PASS. Non-members or actors outside workspace or tenant scope remain `404`; in-scope members missing the capability remain `403`; latest-handoff visibility uses the same boundary as submit. +- Global search safety: PASS. No new Filament resource or globally searchable surface is introduced. +- Run observability / Ops UX: PASS. The slice is intentionally synchronous and does not add queue work or `OperationRun` usage. +- Proportionality / `PROP-001`, `ABSTR-001`, `PERSIST-001`, `STATE-001`, `BLOAT-001`: PASS. The only new persisted truth is four bounded columns on an existing canonical row, one small handoff mode family, and one concrete provider-owned service for one real target. +- Shared pattern reuse / `XCUT-001`: PASS. The plan extends the existing support-request service and existing support-aware action surfaces instead of creating page-local handoff logic. +- Provider boundary / `PROV-001`: PASS. Provider semantics stay confined to the concrete handoff service; platform truth remains neutral. +- Filament-native UI / `UI-FIL-001`: PASS. The flow stays inside native Filament action forms and notifications. +- Livewire v4 / Filament v5 compliance: PASS. The plan stays on the current Filament v5 and Livewire v4 stack. +- Provider registration location: PASS. No provider registration changes are needed; Laravel 11+ provider registration remains in `bootstrap/providers.php`. +- Destructive action confirmation: PASS. No destructive action is added, so no new `->requiresConfirmation()` path is introduced. +- Asset strategy: PASS. No new panel or shared assets are required; deployment behavior for `cd apps/platform && php artisan filament:assets` remains unchanged. +- Test governance / `TEST-GOV-001`: PASS. Proof remains in focused unit plus feature lanes, with manual smoke only as implementation close-out. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for handoff branching, target-unavailable fallback, provider normalization, and latest-summary derivation; Feature for tenant and run action behavior, authorization boundaries, persisted linkage truth, partial-success feedback, and audit events +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: the feature is server-driven and synchronous; business truth lives in the submission service, persistence, and authorization boundaries, so browser automation would mostly duplicate what Pest can already prove through Livewire and domain tests +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php` +- **Fixture / helper / factory / seed / context cost risks**: reuse existing workspace, tenant, operation run, user membership, and support-request fixtures; add only a small fake for the one external target and a narrow latest-summary assertion helper if needed +- **Expensive defaults or shared helper growth introduced?**: no +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: standard-native-filament relief applies on the tenant dashboard action; the run viewer remains under its monitoring-state-page contract and needs the same tenant-entitlement checks as the current support action +- **Closing validation and reviewer handoff**: re-run the exact unit and feature commands above, then manually smoke create, link, and failure handling from both existing surfaces; reviewers should explicitly verify that no support-request resource, queue, settings UI, or global-search behavior was added +- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep +- **Review-stop questions**: did implementation add a new support table, a support-request resource, a support settings UI, a multi-provider registry, or queue or `OperationRun` behavior that the spec forbids? +- **Escalation path**: reject-or-split if target-configuration management, multi-provider support, or retry orchestration appears during implementation +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage +- **Why no dedicated follow-up spec is needed**: the delivery cost stays local to the existing support-request path; broader configuration or multi-provider expansion is separate work, not latent scope inside this slice + +## Implementation Close-Out — Guardrail / Exception / Smoke Coverage + +- **Guardrail outcome**: PASS. The implementation extends only the existing tenant-dashboard and operation-run `Request support` actions, keeps the run support action grouped under `More`, and does not add a support-request resource, support queue, global-search surface, target-management UI, provider registry, or new `OperationRun` behavior. +- **Finalization exception outcome**: PASS. The only post-create mutation on `support_requests` is the Spec 256 bounded finalization write to `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary`; invalid linked-ticket input is rejected before the internal support request is created. +- **Smoke coverage outcome**: PASS. A temporary Pest Browser smoke harness loaded the tenant dashboard and run detail, submitted tenant `create_external_ticket`, submitted run `link_existing_ticket`, forced run create failure, reopened the run support action to verify the latest failure summary, and asserted no browser console or JavaScript errors. The temporary browser test was removed after execution so the permanent coverage remains the planned unit plus feature lanes. +- **Follow-up decision**: No in-scope follow-up spec is required. Target-management UI, retry/relink workflows, and multi-provider support remain explicit future-spec candidates only if product pressure proves them necessary. + +## Project Structure + +### Documentation (this feature) + +```text +specs/256-external-support-desk-handoff/ +├── checklists/ +│ └── requirements.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── external-support-desk-handoff.logical.openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ └── Pages/ +│ │ ├── Operations/ +│ │ │ └── TenantlessOperationRunViewer.php +│ │ └── TenantDashboard.php +│ ├── Models/ +│ │ └── SupportRequest.php +│ ├── Services/ +│ │ └── Audit/ +│ │ └── WorkspaceAuditLogger.php +│ └── Support/ +│ ├── Audit/ +│ │ └── AuditActionId.php +│ └── SupportRequests/ +│ ├── SupportRequestContextBuilder.php +│ ├── SupportRequestReferenceGenerator.php +│ ├── SupportRequestSubmissionService.php +│ └── ExternalSupportDeskHandoffService.php +├── config/ +│ └── support_desk.php +├── database/ +│ ├── factories/ +│ │ └── SupportRequestFactory.php +│ └── migrations/ +│ └── *_add_external_handoff_fields_to_support_requests_table.php +├── lang/ +│ ├── de/ +│ │ └── localization.php +│ └── en/ +│ └── localization.php +└── tests/ + ├── Feature/SupportRequests/ + │ ├── OperationRunSupportRequestExternalHandoffTest.php + │ ├── SupportRequestExternalHandoffAuditTest.php + │ ├── SupportRequestExternalHandoffAuthorizationTest.php + │ └── TenantSupportRequestExternalHandoffTest.php + └── Unit/Support/SupportRequests/ + ├── ExternalSupportDeskHandoffServiceTest.php + └── SupportRequestLatestHandoffSummaryTest.php +``` + +**Structure Decision**: Single Laravel application. The slice extends the existing support-request domain and two existing Filament pages only. One minimal application config contract in `config/support_desk.php` is in scope so target resolution is explicit, while workspace settings UI and a support settings domain remain out of scope. The constitution-mandated checklist in `checklists/requirements.md` stays part of the implementation handoff set. + +## Complexity Tracking + +| Violation / review item | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| Extend `support_requests` with four external-handoff columns | The operator must be able to revisit the current support context and still see the same external linkage or failure truth on the canonical support request | A separate `support_tickets` table would create a second lifecycle and a new surface the current slice does not need | +| Add one concrete provider-owned handoff service | One real external target must be called or normalized from both existing support-aware surfaces without page-local HTTP logic | A generic interface, registry, or multi-provider adapter catalog would be premature because the repo has exactly one current-release target case | + +## Proportionality Review + +- **Current operator problem**: the product can already create an internal support request with redacted context, but operators still have to create or paste an external desk ticket manually outside TenantPilot and then remember that linkage separately +- **Existing structure is insufficient because**: the current service ends at internal persistence and cannot carry durable external linkage or explicit failure truth back into the current support context +- **Narrowest correct implementation**: extend the existing `SupportRequest` row with minimal neutral linkage fields, route create or link decisions through the existing submission service, and render the latest linkage only inside the same two support-aware actions +- **Ownership cost created**: one additive migration, one concrete provider-owned service, a few audit IDs and audit-logger methods, modest action-form growth on two pages, and focused tests +- **Alternative intentionally rejected**: a new support-ticket model, support-request resource or detail page, target-management UI, provider registry, background retry path, or `OperationRun` delivery orchestration were all rejected as broader than current-release truth +- **Release truth**: current-release support follow-through and commercialization gap, not future ITSM platform preparation + +## Implementation Outline + +### 1. Support request persistence extension + +- Add the four external-handoff columns to `support_requests`. +- Default `external_handoff_mode` to `internal_only` so existing rows remain truthful without compatibility shims. +- Keep the internal `SR-...` reference canonical for every request. + +### 2. Submission service orchestration + +- Continue to authorize and validate through the current `SupportRequestSubmissionService` path. +- Persist the internal support request first and keep `WorkspaceAuditLogger::logSupportRequestCreated(...)` unchanged for that stage. +- Branch by handoff mode after the internal row exists: + - `internal_only`: return immediately with no external fields populated + - `link_existing_ticket`: validate and normalize the provided reference or URL locally, persist linkage, and audit `linked` + - `create_external_ticket`: call one concrete provider-owned handoff service outside the initial DB transaction with the five-second timeout budget, then perform the one allowed synchronous finalization write back to the same row and audit the outcome + +### 3. Latest-summary derivation + +- Add one shared read path for the latest handoff summary per primary context. +- Tenant summary queries the latest `support_requests` row for the current tenant where `primary_context_type = tenant`. +- Run summary queries the latest `support_requests` row for the current run where `primary_context_type = operation_run` and `operation_run_id` matches the viewed run. +- The visible summary always includes the internal support reference it belongs to. + +### 4. Filament surface extension + +- Extend the existing `Request support` action on both pages with: + - mutation-scope guidance (`TenantPilot only` versus `TenantPilot + external support desk`) + - handoff mode choice + - conditional external reference and URL inputs for `link_existing_ticket` + - a read-only latest-handoff summary placeholder scoped to the current context +- Keep `Open support diagnostics` unchanged as the diagnostics-secondary affordance. +- Success notifications include the internal reference and, when present, the external reference. +- External create failure uses explicit partial-success or warning feedback: internal request created, external handoff failed. + +### 5. Audit and copy consistency + +- Add stable audit action IDs for: + - external ticket created + - external ticket linked + - external handoff failed +- Keep audit context bounded to workspace, tenant, internal support reference, primary context, handoff mode, and external ticket reference when present. +- Preserve neutral UI copy and do not surface provider product names as the primary operator vocabulary. + +## Implementation Phases + +1. **Foundation**: add the migration shape, model casts and constants, audit IDs, the concrete handoff service contract for one target, and the minimal `config/support_desk.php` contract. +2. **Submission flow**: refactor `SupportRequestSubmissionService` so internal creation commits first, then link or create outcome persists back to the same row. +3. **Surface wiring**: extend the tenant dashboard and run viewer forms with handoff mode, latest-summary placeholder, and outcome-sensitive notification copy. +4. **Hardening**: add latest-summary derivation, target-unavailable fallback to `internal_only`, authorization proof, and audit proof. + +## Guardrail Close-Out Expectations + +- Livewire v4 compatibility remains unchanged because the flow stays inside existing Filament v5 page actions. +- Laravel 12 provider registration facts remain unchanged: panel providers stay in `bootstrap/providers.php`. +- No globally searchable resource is added, so there is no new global-search contract to satisfy. +- No destructive action is introduced, so there is no new confirmation flow requirement. +- No new assets are required; `cd apps/platform && php artisan filament:assets` stays part of the general deployment path but does not change for this feature. diff --git a/specs/256-external-support-desk-handoff/quickstart.md b/specs/256-external-support-desk-handoff/quickstart.md new file mode 100644 index 00000000..d0ee5055 --- /dev/null +++ b/specs/256-external-support-desk-handoff/quickstart.md @@ -0,0 +1,48 @@ +# Quickstart — External Support Desk / PSA Handoff + +## Prereqs + +- Docker is running. +- Laravel Sail dependencies are installed. +- The support-request foundation from Spec 246 is already present in the workspace. +- One application-configured external support desk target is available through `apps/platform/config/support_desk.php`, or a fake target is available for implementation tests. + +## Run locally after implementation + +- Start containers: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d` +- Run targeted unit proof: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php` +- Run targeted feature proof: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php` +- Format after implementation: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## Manual smoke after implementation + +1. Sign in to `/admin` as a workspace member with tenant entitlement and `support_requests.create` capability. +2. Open one tenant at `/admin/t/{tenant}` and trigger `Request support`. +3. Verify the action shows the existing context summary plus the new handoff mode controls. If no external target is configured in `config/support_desk.php`, verify the action clearly stays in `internal_only` mode. +4. Submit the tenant-context flow with `create_external_ticket` and verify the success notification includes the internal support reference plus the created external ticket reference. +5. Reopen the tenant-context action and verify the latest-handoff summary names the same internal support reference and external ticket reference. +6. Submit the tenant-context flow with `link_existing_ticket` and verify the stored summary shows the linked external reference without issuing a create call. +7. Force the external create path to fail, including the five-second timeout path, and verify the action returns explicit partial-success or warning feedback, the internal support request still exists, and the latest-handoff summary shows the persisted failure summary. +8. Open one canonical run detail page at `/admin/operations/{run}` for a run that resolves to the same entitled tenant scope and repeat create, link, and failure checks there. +9. Verify a non-member or non-entitled actor receives `404`, while an in-scope member without `support_requests.create` sees the action disabled and receives `403` on execution. +10. Verify no new support-request resource, support queue, global-search result, or `OperationRun` side effect appears in this slice. + +## Notes + +- Filament v5 remains on Livewire v4.0+ in this repo; the feature stays inside native Filament page actions. +- No provider registration change is part of this slice; Laravel 12 panel providers remain registered in `bootstrap/providers.php`. +- No globally searchable resource is added, so there is no new global-search contract to satisfy. +- No destructive action is introduced, so `->requiresConfirmation()` is not newly involved here. +- No asset strategy changes are required. The general deploy step `cd apps/platform && php artisan filament:assets` remains unchanged. + +## Implementation Close-Out Expectations + +- The targeted unit and feature commands above pass. +- Manual smoke proves create, link, and explicit failure handling from both existing support-aware surfaces. +- Audit review shows `support_request.created`, `support_request.external_ticket_created`, `support_request.external_ticket_linked`, and `support_request.external_handoff_failed` events with the expected bounded metadata. +- Internal-only support-request submission still works when the external target is unavailable or intentionally bypassed. +- No new support product surface appears beyond the two existing actions. \ No newline at end of file diff --git a/specs/256-external-support-desk-handoff/research.md b/specs/256-external-support-desk-handoff/research.md new file mode 100644 index 00000000..74c18fc9 --- /dev/null +++ b/specs/256-external-support-desk-handoff/research.md @@ -0,0 +1,167 @@ +# Research — External Support Desk / PSA Handoff + +**Date**: 2026-04-29 +**Spec**: [spec.md](spec.md) + +This document records the repo-grounded decisions that make the Spec 256 plan implementation-ready without expanding into a generic helpdesk product. + +## Decision 1 — Extend `support_requests` instead of adding a second support-ticket truth + +**Decision**: Keep `App\Models\SupportRequest` as the only persisted truth for this slice and add the external linkage fields directly to `support_requests`. + +**Rationale**: +- The repo already has one canonical support-request model, migration, factory, and submission service. +- The operator workflow needs one durable record that still carries the internal `SR-...` reference after create, link, or failure. +- Constitution `PERSIST-001` and `PROP-001` reject a second lifecycle unless it solves a distinct product problem. Spec 256 does not need one. + +**Evidence**: +- Existing model: `apps/platform/app/Models/SupportRequest.php` +- Existing persistence: `apps/platform/database/migrations/2026_04_27_095518_create_support_requests_table.php` +- Existing write path: `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` +- Candidate scope: `docs/product/spec-candidates.md` + +**Alternatives considered**: +- Add a new `SupportTicket` or `SupportRequestLink` model. + - Rejected: creates a second truth and encourages a support register or detail page the spec explicitly forbids. +- Keep external linkage entirely derived from audit logs. + - Rejected: the current support context must show the latest linkage on revisit, which audit-only storage cannot do safely or cheaply. + +## Decision 2 — Persist a bounded failure summary on the same row; keep detailed provider failure out of product truth + +**Decision**: Store `external_handoff_failure_summary` on `support_requests` and keep detailed provider payloads or raw errors out of persisted support-request truth. + +**Rationale**: +- The spec requires explicit, revisitable failure handling in the same support context. +- A purely audited failure would satisfy compliance but fail the operator need to reopen the action and see what happened. +- A bounded human-readable summary is enough for revisit. Provider-specific payloads remain provider-owned and redaction-sensitive. + +**Evidence**: +- Existing audit path is already separate from product truth: `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` +- Current support-request row has no external linkage or failure fields, so the revisit contract is impossible without row-level extension. + +**Alternatives considered**: +- Audit failure only. + - Rejected: failure becomes invisible in the current support context. +- Persist raw provider response JSON. + - Rejected: violates the spec’s minimal neutral truth and increases leakage risk. + +## Decision 3 — Keep the flow synchronous, preserve internal durability, and document the one bounded finalization write + +**Decision**: Preserve the existing synchronous submit flow, move any external create call outside the current internal-request creation transaction, enforce a five-second outbound timeout, and explicitly allow one bounded post-create finalization write on the same `SupportRequest` row. + +**Rationale**: +- The current service wraps internal create plus audit in a transaction. +- Spec 256 explicitly requires the internal support request to survive external create failure. +- Spec 246 declared the row immutable after creation, so Spec 256 must make its one bounded finalization exception explicit instead of mutating the row silently. +- Holding a database transaction open across remote HTTP is unnecessary and increases latency and failure risk. +- A hard timeout budget is needed so the operator-visible submit path stays bounded and timeout behavior is testable. +- The repo truth does not require `OperationRun`, queueing, or retry scheduling for this slice. + +**Evidence**: +- Existing transaction structure in `SupportRequestSubmissionService` +- Existing synchronous page actions on `TenantDashboard` and `TenantlessOperationRunViewer` +- The spec’s explicit non-goal for queues, retries, and `OperationRun` +- Spec 246 FR-246-011 immutability contract + +**Alternatives considered**: +- Perform external HTTP inside the current DB transaction. + - Rejected: risks long transactions and makes internal request durability harder to guarantee. +- Introduce queue work or `OperationRun`. + - Rejected: broader than current-release truth and not required for one synchronous target. +- Keep Spec 246 immutability unchanged and infer final handoff state only from audit. + - Rejected: the current support context must show revisitable success or failure on the canonical `SupportRequest`, so the one bounded finalization write has to be explicit. + +## Decision 4 — Keep external linkage visibility inside the existing support request actions only + +**Decision**: Show the latest linkage summary inside the existing `Request support` slide-overs and in submit feedback. Do not add a new support page, dashboard card, or run-detail history section. + +**Rationale**: +- The spec says visibility must stay attached to the existing tenant and run support contexts. +- The acceptance criteria require reopening the action and seeing the latest linkage summary for that same context. +- A broader always-visible history surface would deepen support-product scope and duplicate truth. + +**Evidence**: +- Existing support-aware surfaces: `apps/platform/app/Filament/Pages/TenantDashboard.php` and `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` +- No existing `SupportRequest` resource, list, or detail page exists in the repo today. + +**Alternatives considered**: +- Add a support-request resource or detail page. + - Rejected: explicitly out of scope. +- Add a new page-level widget or card for support linkage. + - Rejected: broader than the acceptance requirement and would create duplicate visible truth. + +## Decision 5 — Add one minimal application config contract; do not hide target resolution behind an undefined prerequisite + +**Decision**: Bring one minimal application config contract into scope through `apps/platform/config/support_desk.php` and environment-backed values for the single supported target. Do not add workspace settings UI, a support settings domain, or provider-connection product work. + +**Rationale**: +- The repo has no existing `support` settings domain, so leaving target resolution as an external prerequisite would create hidden implementation work. +- The product contract only needs one target for v1, so an application config contract is the narrowest explicit source of truth. +- Pulling workspace administration into Spec 256 would still expand scope from handoff to setup and administration. + +**Evidence**: +- Existing repo truth has no support-target config seam yet, so a new app config file is the explicit minimal source of truth for one target. + +**Alternatives considered**: +- Add a new `support` settings domain and UI in the same spec. + - Rejected: becomes a second feature slice. +- Reuse `ProviderConnection` as the support target model. + - Rejected: not justified by current repo truth for one external desk handoff target. +- Leave target resolution as an undefined prerequisite. + - Rejected: the tasks and plan already depend on a concrete resolution seam, so the config contract must be explicit inside the package. + +## Decision 6 — Use one concrete provider-owned handoff service, not a registry or interface framework + +**Decision**: Add one concrete provider-owned handoff service under the support-request path for the single real external target. + +**Rationale**: +- Both existing support surfaces need the same create-or-normalize behavior. +- Constitution `ABSTR-001` rejects a provider registry or interface framework before two real targets exist. +- Page-local HTTP logic would duplicate failure handling, normalization, and audit shape. + +**Evidence**: +- One configured target only in the spec and roadmap candidate +- Existing shared write path already centralizes support-request submission across both surfaces + +**Alternatives considered**: +- Add a provider interface plus registry. + - Rejected: future-proofing without current-release variance. +- Duplicate HTTP logic inside both Filament pages. + - Rejected: immediate drift risk and weaker audit consistency. + +## Decision 7 — Keep queries context-scoped and avoid new search or indexing semantics + +**Decision**: Derive the latest visible linkage from the latest support request for the same primary context, using the existing context indexes. Do not add cross-scope lookup or search by external ticket reference. + +**Rationale**: +- Tenant summary and run summary have different scope rules in the spec. +- Existing indexes already support latest-by-tenant and latest-by-run queries. +- Cross-scope lookup by external reference is explicitly out of scope and would create a new leakage risk. + +**Evidence**: +- Existing indexes on `support_requests(tenant_id, created_at)` and `support_requests(operation_run_id, created_at)` +- Context scoping in `SupportRequest::PRIMARY_CONTEXT_TENANT` and `SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN` + +**Alternatives considered**: +- Add an index and lookup flow for external ticket reference. + - Rejected: no current surface needs it, and it would conflict with the no-cross-scope-shortcuts rule. + +## Decision 8 — Proof stays in Unit + Feature lanes with manual smoke only + +**Decision**: Keep the proving strategy in focused Pest unit and feature suites, then use a narrow manual smoke path after implementation. + +**Rationale**: +- Business truth is server-side: branching, persistence, audit, and authorization. +- Existing support-request tests already cover the same two Filament entry surfaces. +- Browser coverage would mostly duplicate the existing action-form semantics. + +**Evidence**: +- Existing test family: + - `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestActionTest.php` + - `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestActionTest.php` + - `apps/platform/tests/Feature/SupportRequests/SupportRequestAuthorizationTest.php` + - `apps/platform/tests/Feature/SupportRequests/SupportRequestAuditTest.php` + +**Alternatives considered**: +- Add browser tests in the first slice. + - Rejected: not required to prove the current business truth. \ No newline at end of file diff --git a/specs/256-external-support-desk-handoff/spec.md b/specs/256-external-support-desk-handoff/spec.md new file mode 100644 index 00000000..11fc59ea --- /dev/null +++ b/specs/256-external-support-desk-handoff/spec.md @@ -0,0 +1,331 @@ +# Feature Specification: External Support Desk / PSA Handoff + +**Feature Branch**: `256-external-support-desk-handoff` +**Created**: 2026-04-29 +**Status**: Draft +**Input**: User description: "Prepare the next open candidate External Support Desk / PSA Handoff as the narrowest repo-grounded slice that extends the already-implemented in-app support request flow with one-way external ticket create or link behavior, stores the resulting external reference on the existing support-request truth, and keeps visibility on the existing tenant and operation-run support contexts only." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot already captures support requests with internal `SR-...` references, redacted context, and audit truth, but support follow-through still breaks at the product boundary because operators must create or paste external service-desk tickets manually outside the current workflow. +- **Today's failure**: A tenant or run-scoped support request can be submitted from the product, yet the product cannot tell the operator whether an external ticket was created, linked, or failed. That creates manual duplicate work, weakens audit continuity, and leaves no durable external-ticket linkage in the current support context. +- **User-visible improvement**: The existing `Request support` action can create a new external desk ticket or link an already-created ticket through one configured external desk target, then show the resulting external reference or explicit failure in the same tenant or run support context. +- **Smallest enterprise-capable version**: Extend the existing `SupportRequest` truth and `SupportRequestSubmissionService` so one configured external support desk target can be used during the existing tenant-dashboard and operation-run support flows, persist the resulting external ticket reference or last handoff failure on the same support request, audit create or link outcomes, and surface the latest handoff summary only on those same support contexts. +- **Explicit non-goals**: No new support-request creation flow, no support-request resource/list/detail page, no support inbox or queue product, no generic helpdesk framework, no multi-provider adapter registry, no bidirectional sync, no external ticket status polling, no SLA engine, no retry scheduler, no AI support automation, and no cross-workspace or cross-tenant handoff shortcuts. +- **Permanent complexity imported**: One bounded provider-owned handoff adapter for a single configured external target, a small external-handoff mode family on the existing support-request truth, a nullable persisted external ticket reference and URL on `support_requests`, a bounded persisted handoff failure summary, targeted audit action IDs, and focused unit plus feature coverage. +- **Why now**: `docs/product/spec-candidates.md` and `docs/product/roadmap.md` both confirm that support-request creation is already repo-real and that the remaining commercialization gap is external handoff plus visible ticket linkage, not another internal support intake feature. +- **Why not local**: Page-local ticket creation or a manual copy field on each surface would duplicate logic that already lives in `SupportRequestSubmissionService`, would drift audit and failure behavior between tenant and run contexts, and would still not create one durable support-request-to-external-ticket truth. +- **Approval class**: Workflow Compression +- **Red flags triggered**: New provider seam, new persisted fields on an existing truth model, and multi-surface action changes. Defense: the slice extends one existing model and one existing submission path, stays on two already-support-aware surfaces, and explicitly forbids a generic helpdesk or queue framework. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant, canonical-view +- **Primary Routes**: + - existing tenant dashboard at `/admin/t/{tenant}` via `App\Filament\Pages\TenantDashboard` + - existing canonical operation detail at `/admin/operations/{run}` via `App\Filament\Pages\Operations\TenantlessOperationRunViewer` + - no new dedicated support desk or support-request route in v1 +- **Data Ownership**: + - `support_requests` remains the canonical tenant-owned truth and continues to carry required `workspace_id` and `tenant_id` + - external handoff truth extends that same record only: external ticket reference, external ticket URL, handoff mode, and last handoff failure are stored on the existing support request rather than in a new ticket-link model or table + - one configured external support desk target is treated as application-configured integration truth and is referenced during handoff, but it is not mirrored into tenant-owned support-request records beyond the neutral external linkage fields needed for operator continuity and auditability +- **RBAC**: + - workspace membership and tenant entitlement remain the first isolation boundaries + - the existing `Capabilities::SUPPORT_REQUESTS_CREATE` capability continues to gate support-request submission and any visible create-or-link external handoff controls + - non-members or actors not entitled to the workspace or tenant scope receive `404` + - members inside scope who lack `Capabilities::SUPPORT_REQUESTS_CREATE` receive `403` + - run-context handoff and any latest-handoff summary on the run page resolve the run's tenant first and must not reveal linkage state for a tenant the actor cannot access + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: `N/A` - the feature does not add a canonical collection page; the operation-run surface stays bound to the currently opened run only. +- **Explicit entitlement checks preventing cross-tenant leakage**: Any lookup used to show a latest external handoff summary on the operation-run support context must resolve through the current run's entitled tenant scope and the current workspace. Known internal support references or external ticket references must not bypass that scope check. + +## 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 +- **Interaction class(es)**: header actions, contextual support capture, success and failure notifications, support-context summaries, audit events, and external-link navigation +- **Systems touched**: `App\Filament\Pages\TenantDashboard`, `App\Filament\Pages\Operations\TenantlessOperationRunViewer`, `App\Support\SupportRequests\SupportRequestSubmissionService`, `App\Support\SupportRequests\SupportRequestContextBuilder`, `App\Support\SupportRequests\SupportRequestReferenceGenerator`, `App\Services\Audit\WorkspaceAuditLogger`, `App\Support\Audit\AuditActionId`, `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder`, and existing `UiEnforcement` capability gating on both support actions +- **Existing pattern(s) to extend**: the current `Request support` slide-over actions, current support-request success feedback, current support-diagnostics context summary, current audit logging path, and current tenant/run support authorization boundaries +- **Shared contract / presenter / builder / renderer to reuse**: `SupportRequestSubmissionService`, `SupportRequestContextBuilder`, `WorkspaceAuditLogger`, `UiEnforcement`, and the existing support-diagnostics bundle as the canonical redacted context source +- **Why the existing shared path is sufficient or insufficient**: The current shared path already assembles support-safe context, issues the internal `SR-...` reference, and writes audit truth consistently from both existing entry surfaces. It is insufficient only because it stops at internal persistence and cannot persist or surface external desk follow-through. +- **Allowed deviation and why**: One provider-owned external handoff adapter or service is allowed inside the support-request path because one configured external desk target must be called or normalized from both surfaces. No second page-local handoff client, no generic helpdesk registry, and no parallel support-summary vocabulary are allowed. +- **Consistency impact**: `Request support`, `Support reference`, `External ticket`, `Create external ticket`, `Link existing ticket`, and handoff failure wording must have the same meaning on tenant and run surfaces, in success or failure notifications, and in audit summaries. +- **Review focus**: Reviewers must block any page-local external desk payload builder, any second support-ticket persistence model, and any new support status language that duplicates the existing support-request truth instead of extending it. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: no +- **Shared OperationRun UX contract/layer reused**: `N/A` +- **Delegated start/completion UX behaviors**: `N/A` +- **Local surface-owned behavior that remains**: The operation-run page continues to use the current run only as support context. External desk handoff must not create, resume, or otherwise mutate an `OperationRun`. +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: yes +- **Boundary classification**: provider-owned +- **Seams affected**: outbound create request payloads, external ticket URL and reference normalization, external-target credential resolution, provider-specific response parsing, and provider-specific failure normalization +- **Neutral platform terms preserved or introduced**: support request, support reference, external ticket, external ticket reference, external ticket URL, external handoff mode, external handoff failure, and latest handoff summary +- **Provider-specific semantics retained and why**: Authentication, request payload shape, URL templates, provider-specific ticket IDs, and provider-specific validation rules remain inside the one configured external desk adapter because only one concrete target exists in the current release slice. +- **Why this does not deepen provider coupling accidentally**: The `SupportRequest` record stores only neutral linkage truth needed for operator continuity: the external reference, optional URL, selected handoff mode, and explicit last failure summary. It does not store provider-specific fields such as assignee, queue, SLA, raw payloads, or external status history. +- **Follow-up path**: `follow-up-spec` only if a second real external desk target exists or the first target proves that a provider-neutral shared boundary is genuinely needed. + +## 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 dashboard `Request support` action | yes | Native Filament action + shared support primitives | header actions, support capture, support diagnostics, success or failure notifications | page, action form, bounded latest-handoff summary | yes | Existing tenant-dashboard action-surface exception remains bounded; the feature extends the current slide-over instead of adding a support page | +| Operation run `Request support` action | yes | Native Filament action + shared support primitives | grouped detail actions, support capture, monitoring-state support context, success or failure notifications | detail page, action form, bounded latest-handoff summary | no | Extends an already support-aware run-detail action instead of adding a second run-support surface | + +## 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 dashboard `Request support` action | Secondary Context Surface | The operator decides that the current tenant issue needs external escalation or explicit desk linkage | current support summary, external handoff mode choice, latest external handoff summary when one exists, and what submitting will write | deeper support diagnostics remain on the neighboring `Open support diagnostics` action | Secondary because the operator is still primarily troubleshooting the tenant, not working in a support-desk inbox | Follows current tenant troubleshooting and support-escalation flow | Removes manual copy-paste and out-of-band ticket bookkeeping from the tenant troubleshooting path | +| Operation run `Request support` action | Secondary Context Surface | The operator decides that the current run already contains enough context to hand off or link to an external desk | run identity, external handoff mode choice, latest external handoff summary when one exists, and what submitting will write | deeper run diagnostics remain on the existing support-diagnostics action and run detail sections | Secondary because the operator is still primarily inspecting one run | Follows current run drill-in workflow | Removes the need to recreate the same run context in an external desk after the operator has already drilled into it | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Tenant dashboard `Request support` action | operator-MSP, support-platform | support summary, selected mutation scope, handoff mode choice, latest external ticket reference or last failure, and required form fields | redacted support diagnostics remain secondary and separately opened | raw provider payloads, external desk raw payloads, and provider-specific response bodies stay hidden | `Submit support request` | diagnostics remain capability-gated; any provider-specific fields stay inside the adapter and never appear as default-visible operator content | the slide-over states the current handoff truth once and links it to a specific internal support reference instead of duplicating support-request history blocks | +| Operation run `Request support` action | operator-MSP, support-platform | run identity, mutation scope note, handoff mode choice, latest external ticket reference or last failure, and required form fields | redacted run diagnostics remain secondary and separately opened | raw provider payloads, external desk raw payloads, and provider-specific response bodies stay hidden | `Submit support request` | diagnostics remain capability-gated; run detail stays the primary evidence surface | the slide-over shows only the latest bounded linkage summary for this run context instead of becoming a support-request register | + +## 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 dashboard `Request support` action | Dashboard / Overview / Actions | Tenant support escalation entry point | submit the support request with one chosen external handoff mode | explicit header action opens the existing slide-over | forbidden | `Open support diagnostics` remains the neighboring secondary action; any external link stays inside the same support context summary | none | `/admin/t/{tenant}` | `/admin/t/{tenant}` | active workspace, active tenant, and current support context summary | Support request / External ticket | whether the next submit stays internal-only, creates an external ticket, or links an existing ticket | dashboard_exception - existing tenant dashboard action-surface exception remains bounded and justified by the dashboard's role as the tenant troubleshooting hub | +| Operation run `Request support` action | Record / Detail / Actions | Run-centered support escalation entry point | submit the support request with one chosen external handoff mode | explicit detail action in the existing grouped support actions | forbidden | `Open support diagnostics` remains grouped beside `Request support`; any external link stays inside the same support slide-over | none | `/admin/operations` | `/admin/operations/{run}` | workspace context, entitled tenant context, and current operation identifier | Support request / External ticket | whether the current run context already has an external ticket linkage or a visible last handoff failure | 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 dashboard `Request support` action | Workspace manager or support-capable tenant operator | Decide whether this tenant issue should stay internal, create a new external ticket, or link an existing ticket | Dashboard action + contextual slide-over | How do I hand this tenant issue off without losing the current support-request truth? | internal support reference after submit, chosen handoff mode, latest external ticket reference or failure, summary, contact defaults, and context attachment summary | full support diagnostics remain in the neighboring diagnostics surface | external handoff mode, external linkage presence, handoff failure presence, attachment completeness | TenantPilot only or TenantPilot + external support desk, depending on the selected handoff mode | Submit support request | none | +| Operation run `Request support` action | Workspace manager or support-capable operator | Decide whether this run issue should stay internal, create a new external ticket, or link an existing ticket | Detail action + contextual slide-over | How do I hand this run issue off without recreating the case outside the product? | internal support reference after submit, chosen handoff mode, latest external ticket reference or failure, summary, contact defaults, and run-context attachment summary | full run diagnostics remain in the run detail and diagnostics surface | external handoff mode, external linkage presence, handoff failure presence, attachment completeness | TenantPilot only or TenantPilot + external support desk, depending on the selected handoff mode | Submit support request | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no - the feature extends the existing `SupportRequest` truth rather than introducing a second ticket-link model or support queue truth +- **New persisted entity/table/artifact?**: no new table; yes, the existing `support_requests` truth gains bounded external handoff fields needed for operator continuity and auditability +- **New abstraction?**: yes - one provider-owned external handoff adapter or service for the single configured target +- **New enum/state/reason family?**: yes - one small external handoff mode family (`create_external_ticket`, `link_existing_ticket`, `internal_only`) with direct operator and mutation consequences +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: support requests already exist, but the product still cannot show whether an external desk ticket exists or was attempted from the same support context +- **Existing structure is insufficient because**: the current submission service and UI end at internal persistence and cannot safely call or normalize an external desk, store the resulting reference, or keep failure truth visible for the current context +- **Narrowest correct implementation**: extend the current `SupportRequest` submission path and current tenant or run support actions only, add one provider-owned handoff adapter for one configured target, and store only the minimal linkage truth on the same support request +- **Ownership cost**: extra `support_requests` columns, one provider-owned handoff adapter or service, stable audit IDs for external handoff outcomes, slightly richer action forms, and focused unit plus feature coverage +- **Alternative intentionally rejected**: a new `SupportTicket` model, a support-request detail resource, or a generic multi-provider helpdesk framework was rejected because the repo currently has only one real external desk use case and already has the support-request truth needed for v1 +- **Release truth**: current-release support follow-through and commercialization gap, not future multi-provider preparation + +### 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**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: Unit coverage can prove the external handoff adapter normalization rules, handoff mode branching, and latest-handoff summary derivation cheaply. Focused Filament feature coverage can prove tenant and run action behavior, `404` versus `403` boundaries, persisted linkage truth, explicit failure handling, and audit events without needing browser-only coverage. +- **New or expanded test families**: one bounded `Unit/Support/SupportRequests/ExternalSupportDesk*` family and one bounded `Feature/SupportRequests/*ExternalHandoff*` family +- **Fixture / helper cost impact**: moderate. Reuse existing workspace, tenant, operation run, support request, and authorization fixtures. Add only the narrow target-configuration and adapter-fake setup needed for create or link success and failure paths. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: standard-native-filament, monitoring-state-page +- **Standard-native relief or required special coverage**: standard Filament action coverage is sufficient for the tenant dashboard action. The run-context action must also preserve the existing canonical monitoring-state-page authorization and context rules. +- **Reviewer handoff**: Reviewers must confirm that no support-request resource or queue page appears, that create failures keep the internal support request, that latest external linkage stays scoped to the current entitled context, and that no provider-specific payloads leak into the persisted support-request truth. +- **Budget / baseline / trend impact**: low-to-moderate increase in narrow unit plus feature coverage only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php` + +## External Handoff Contract + +The first slice extends the existing support-request truth instead of creating a second support-ticket product model. + +| Handoff mode | Operator intent | External effect | Stored support-request truth | Default-visible result | +|---|---|---|---|---| +| `create_external_ticket` | Create a new external desk ticket from the current support context | One outbound create call through the configured external desk adapter | external ticket reference, optional external ticket URL, chosen mode, and cleared failure summary on success | success feedback shows the internal support reference plus the created external ticket reference | +| `link_existing_ticket` | Record an external ticket that already exists outside the product | No outbound ticket-create call; the bounded adapter may normalize the provided reference or URL for the configured target | operator-supplied external ticket reference, optional external ticket URL, chosen mode, and no provider payload mirror | success feedback shows the internal support reference plus the linked external ticket reference | +| `internal_only` | Keep the request internal-only when the operator intentionally defers external follow-through or when no target is configured for the application | No outbound call | no external ticket reference, chosen mode, and no external failure summary | the support context clearly states that no external ticket is linked yet | + +Additional rules for v1: + +- The internal `SR-...` support reference remains the canonical TenantPilot support-request identifier even when an external ticket exists. +- V1 does not store external assignee, SLA, comments, status history, or raw provider payloads. +- V1 does not auto-retry failed create calls. If a retry or relink path becomes necessary later, it requires a follow-up spec. + +## Scope Boundaries + +### In Scope + +- extend the existing `SupportRequest` truth and `SupportRequestSubmissionService` rather than introducing a new support domain model +- offer one bounded external handoff mode selector inside the current tenant and run `Request support` actions +- allow one support request to either create an external ticket, link an existing external ticket, or stay internal-only +- call exactly one application-configured external support desk or PSA target when the operator chooses `create_external_ticket` +- store the resulting external ticket reference and optional URL on the same support request record +- store a bounded last handoff failure summary on the same support request when external create fails after the internal request exists +- write explicit audit events for external ticket created, external ticket linked, and external handoff failed +- show the latest external handoff summary for the current tenant or run support context without adding a broad support-product surface +- keep current redacted support context attachment behavior from `SupportRequestContextBuilder` + +### Non-Goals + +- re-specifying or replacing support-request creation from Spec 246 +- creating a `SupportRequestResource`, support-request register, or support-request detail page +- a generic ticketing or helpdesk framework with provider discovery or multiple adapters +- bidirectional sync, external ticket status refresh, webhook ingestion, or comment sync +- SLA, priority routing, assignment, support inbox, triage queue, or customer portal work +- AI-generated support summaries or automation +- background jobs or scheduled retries for external desk delivery +- cross-workspace or cross-tenant linking shortcuts based on a known support reference or ticket reference alone + +## Assumptions + +- Exactly one application-configured external support desk target can be resolved through a minimal config contract added in this slice. This spec does not introduce workspace settings UI, per-workspace target management, or a broader support-desk configuration product surface. +- The existing `Capabilities::SUPPORT_REQUESTS_CREATE` capability is sufficient for v1. No new role family or support-only secondary capability is required. +- The current redacted support context envelope produced by `SupportRequestContextBuilder` is already the canonical payload basis for external handoff. This feature does not redefine the support context contract. +- Internal support-request creation remains allowed even when the external target is unavailable or an external create attempt fails, because the product must preserve the internal support truth and auditability. + +## Risks + +- A synchronous external create call can slow the current support action if the provider-owned handoff service does not enforce the v1 five-second timeout budget and normalize timeout failures into the same bounded failure-summary path. +- If a tenant or run has multiple support requests, the latest-handoff summary can mislead operators unless it also names the internal support reference it belongs to. +- Provider-specific response fields can leak into the support-request truth if the adapter boundary is not enforced strictly. +- The manual `link_existing_ticket` path could grow into a broader external-ticket management surface if it is allowed outside support-request submission. That growth is out of scope for v1. + +## Follow-up Candidates + +- a second external support desk or PSA target only after a concrete second target exists and the first target proves real operator value +- a bounded retry or relink flow from the same support contexts only if repeated external create failures become a proven operator pain point +- a read-only support-request register only if current tenant or run context visibility is no longer sufficient +- bidirectional sync or external ticket status refresh only if operators demonstrate a real need beyond stored reference continuity + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Create a new external ticket from the existing support flow (Priority: P1) + +As a support-capable operator, I want the existing support-request action to create an external desk ticket from the current tenant or run context so I do not have to recreate the same case manually outside TenantPilot. + +**Why this priority**: This is the direct commercialization gap named by the roadmap and candidate. Without outbound create, the product still stops at an internal support request only. + +**Independent Test**: Submit a support request from the tenant dashboard and from the operation-run viewer with `create_external_ticket`, fake the configured external desk target, and verify that the support request keeps the internal `SR-...` reference while also storing the returned external ticket reference and URL. + +**Acceptance Scenarios**: + +1. **Given** an entitled operator opens the tenant dashboard and the application has one configured external desk target, **When** the operator submits the existing `Request support` action with `create_external_ticket`, **Then** the system creates the internal support request, creates one external ticket through the bounded adapter, stores the resulting external ticket reference on that same support request, and returns both references in success feedback. +2. **Given** an entitled operator opens an operation run that resolves to an entitled tenant, **When** the operator submits the existing `Request support` action with `create_external_ticket`, **Then** the system stores the internal support request with the run as primary context and persists the external ticket linkage on that same request. +3. **Given** the current context already has an earlier external handoff summary, **When** the operator opens the current `Request support` action again, **Then** the action shows the latest external linkage summary for that same context without turning the surface into a support-request history page. + +--- + +### User Story 2 - Link an already-existing external ticket during support submission (Priority: P1) + +As a support-capable operator who already opened a desk ticket outside TenantPilot, I want to link that ticket during support-request submission so the product records the same external reference without creating a duplicate external case. + +**Why this priority**: The candidate explicitly requires create or link behavior, and linking an already-created external ticket is the smallest way to avoid duplicates without inventing a broader support-ticket management surface. + +**Independent Test**: Submit the existing tenant or run `Request support` action with `link_existing_ticket`, provide a ticket reference and optional URL, and verify that the support request stores that linkage truth without issuing an external create call. + +**Acceptance Scenarios**: + +1. **Given** an entitled operator already has an external ticket reference, **When** the operator submits the existing tenant-context support action with `link_existing_ticket`, **Then** the system persists the provided external reference on the same support request and records an explicit audit event that the ticket was linked rather than created. +2. **Given** an entitled operator is on the operation-run support context, **When** the operator submits the action with `link_existing_ticket`, **Then** the system links the external reference to the run-scoped support request without creating a new external desk ticket. +3. **Given** the operator leaves the ticket reference blank or otherwise invalid for the bounded target format, **When** the action is submitted with `link_existing_ticket`, **Then** the system rejects the linkage input and does not create misleading external-ticket truth. + +--- + +### User Story 3 - Keep failures explicit, scoped, and auditable (Priority: P2) + +As a support-capable operator, I want external handoff failures to be explicit without losing the internal support request so I can continue follow-through safely and without guessing what happened. + +**Why this priority**: The value of external handoff depends on failure honesty. Silent loss of the desk ticket or silent loss of the internal request would be worse than the current manual workflow. + +**Independent Test**: Force the external adapter to fail during `create_external_ticket`, then verify that the internal support request remains persisted, the current support context shows the latest failure summary for that same support reference, and audit truth records the failed handoff. + +**Acceptance Scenarios**: + +1. **Given** the internal support request is created successfully but the external create call fails, **When** the action completes, **Then** the internal support request remains persisted, the operator receives explicit partial-success feedback with the internal support reference plus the handoff failure, and the failed handoff is audited. +2. **Given** a user is not entitled to the current workspace or tenant scope, **When** they attempt to access tenant or run external handoff state or submit the support action, **Then** the system returns `404` and reveals neither the internal support reference nor any external ticket reference. +3. **Given** a user is entitled to the tenant but lacks `Capabilities::SUPPORT_REQUESTS_CREATE`, **When** they attempt the same action, **Then** the system returns `403` and does not create, link, or reveal external handoff truth. + +### Edge Cases + +- The application may not have an external desk target configured. In that case the existing support-request flow must remain available in `internal_only` mode with an explicit note that no external target is configured. +- An external create call may fail after the internal support request is already committed. The request must remain the canonical support truth and must keep a bounded failure summary rather than disappearing or rolling back silently. +- A tenant or run can have multiple support requests over time. The visible handoff summary on the current support context must clearly identify which internal support reference the shown external ticket reference belongs to. +- An operator may know an external ticket reference but not a URL. The product may store the reference alone in v1 and must not invent a URL it cannot prove. +- The operation-run viewer can only surface latest handoff state when the run resolves to an entitled tenant. Runs without an entitled tenant must continue to resolve as `404` without leaking any linkage hint. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature adds a synchronous outbound create call to one configured external support desk target as part of an existing support-request mutation. It does not create a new `OperationRun`, queue, or scheduler. Successful internal request creation, external ticket creation, external ticket linking, and external handoff failure MUST all be auditable. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature extends the existing `SupportRequest` truth instead of adding a second support-ticket model or queue. The only new semantic family is one bounded handoff mode family because operator choice and resulting mutation behavior differ materially between create, link, and internal-only paths. + +**Constitution alignment (XCUT-001):** Existing `Request support` actions, support-diagnostics context, `SupportRequestSubmissionService`, and `WorkspaceAuditLogger` must be reused. Any new external handoff behavior must plug into that shared path instead of creating separate tenant and run implementations. + +**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** External handoff truth must stay secondary to the current tenant or run troubleshooting workflow. The current support contexts should show only the bounded latest linkage summary or failure, while diagnostics remain separately opened and raw provider details remain hidden. + +**Constitution alignment (PROV-001):** External desk payloads, authentication, and provider-specific identifiers remain provider-owned. The shared product truth remains the existing `SupportRequest` plus neutral external linkage fields only. + +**Constitution alignment (TEST-GOV-001):** Proof stays in unit plus feature lanes only. Browser and heavy-governance coverage are out of scope for the first slice. + +**Constitution alignment (RBAC-UX):** The affected authorization plane remains the tenant-admin `/admin` plane. Non-members and non-entitled users receive `404`. Entitled users lacking `Capabilities::SUPPORT_REQUESTS_CREATE` receive `403`. No raw capability strings or role-string checks may appear in feature code. + +**Constitution alignment (UI-FIL-001):** The feature must continue to use native Filament actions and action forms on the current pages. No custom standalone support desk page or local replacement shell is allowed. + +**Constitution alignment (UI-NAMING-001):** Primary operator-facing copy must preserve `Request support`, `Support reference`, and `External ticket`. Provider-specific product names, payload terminology, or API vocabulary must not replace those primary labels. + +### Functional Requirements + +- **FR-256-001 Existing surfaces only**: The system MUST extend only the existing tenant dashboard and canonical operation-run `Request support` actions for v1. It MUST NOT introduce a new support-request resource, support-request detail view, or support queue page. +- **FR-256-002 Bounded handoff mode choice**: When the application has a configured external desk target, the existing `Request support` action MUST let the operator choose exactly one of `create_external_ticket`, `link_existing_ticket`, or `internal_only`. When no target is configured, the action MUST remain available in `internal_only` mode and MUST explain that no external desk target is configured. +- **FR-256-003 Internal request remains canonical**: Every path in this feature MUST create or preserve the existing internal `SupportRequest` truth first. The internal `SR-...` reference remains the canonical support-request identifier even when an external ticket is created or linked. +- **FR-256-003A Bounded finalization exception to Spec 246 immutability**: Spec 256 explicitly narrows Spec 246 FR-246-011 in one bounded way: after internal request creation, the same `SupportRequest` row MAY receive exactly one synchronous finalization write limited to `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary`. No broader edit, reopen, merge, or status workflow is introduced. +- **FR-256-004 External create path**: When the operator selects `create_external_ticket`, the system MUST call exactly one application-configured external support desk target through one bounded provider-owned adapter, apply a maximum five-second outbound timeout, store the returned external ticket reference on the same support request, and store the external ticket URL when the target returns one. +- **FR-256-005 External link path**: When the operator selects `link_existing_ticket`, the system MUST store the provided external ticket reference on the same support request and MUST NOT issue an external ticket-create call for that request. +- **FR-256-006 Persisted linkage truth**: The existing `support_requests` truth MUST be extended with only the neutral external linkage fields needed for operator continuity: external ticket reference, optional external ticket URL, selected handoff mode, and bounded last handoff failure summary. +- **FR-256-007 No mirrored external lifecycle**: V1 MUST NOT persist or display external assignee, SLA, queue, comment stream, status history, or raw provider payloads. +- **FR-256-008 Failure honesty**: If the external create path fails after the internal support request exists, the system MUST keep the internal request, persist a bounded last handoff failure summary on that same request, and show explicit feedback that the internal request succeeded but the external handoff failed. +- **FR-256-009 Context-safe visibility**: The current tenant and run support contexts MUST show the latest external handoff summary for that same primary context, including the internal support reference it belongs to, without becoming a broad support-request history surface. +- **FR-256-010 Audit coverage**: The system MUST write stable audit entries for support request created, external ticket created, external ticket linked, and external handoff failed, with workspace and tenant context plus the internal support reference and the external ticket reference when present. +- **FR-256-011 Authorization boundaries**: Non-members and non-entitled actors MUST receive `404`. Members in scope who lack `Capabilities::SUPPORT_REQUESTS_CREATE` MUST receive `403`. Latest-handoff visibility, create, and link behavior MUST all enforce the same boundary. +- **FR-256-012 Provider boundary**: Provider-specific authentication, request payload shape, response parsing, and URL normalization MUST remain inside one provider-owned adapter or service. Shared platform code MUST work only with the neutral external linkage truth stored on `SupportRequest`. +- **FR-256-013 No background expansion**: V1 MUST NOT add background jobs, retry scheduling, webhook ingestion, or `OperationRun` usage for external desk delivery. +- **FR-256-014 No cross-scope shortcuts**: A known internal support reference or external ticket reference MUST NOT be sufficient to reveal or mutate linkage truth outside the current entitled workspace and tenant scope. +- **FR-256-015 Mutation-scope clarity**: The existing support actions MUST make it clear whether the current submission writes to `TenantPilot only` or to `TenantPilot + external support desk`, based on the selected handoff mode. +- **FR-256-016 Timeout normalization**: When `create_external_ticket` exceeds the five-second outbound timeout budget or times out for any other target-level reason, the system MUST keep the internal support request, persist a bounded timeout-oriented failure summary on the same row, and route the outcome through the same explicit feedback and audit path as other external create failures. + +## 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 dashboard support context | `App\Filament\Pages\TenantDashboard` | `Request support`, `Open support diagnostics` | `N/A` | `N/A` | `N/A` | `N/A` | `N/A` | one slide-over with `Submit support request` and a standard close action | yes | Existing dashboard action-surface exemption remains. The feature only extends the current `Request support` action with handoff mode choice and latest linkage summary. | +| Operation-run support context | `App\Filament\Pages\Operations\TenantlessOperationRunViewer` | grouped `Open support diagnostics`, `Request support` | `N/A` | `N/A` | `N/A` | `N/A` | `N/A` | one slide-over with `Submit support request` and a standard close action | yes | No new run action group or support page. The feature only extends the existing support action with handoff mode choice and latest linkage summary. | + +## Key Entities *(include if feature involves data)* + +- **Support Request**: Existing tenant-owned support truth with internal reference, primary context, redacted context envelope, severity, and the new bounded external linkage fields needed for external handoff continuity. +- **External Support Desk Target**: The single application-configured external desk or PSA destination used for v1 handoff. It owns provider-specific authentication and payload semantics. +- **External Ticket Linkage**: The bounded support-request extension that records whether the current request stayed internal-only, created an external ticket, or linked an existing one, together with the neutral external ticket reference, optional URL, and last failure summary. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: From the existing tenant dashboard or operation-run support context, an authorized operator can complete support-request submission with external create or link behavior in one flow without leaving the current page to recreate the case manually. +- **SC-002**: 100% of successful external create or link submissions persist an external ticket reference on the same support request and make that reference visible again from the same entitled support context on revisit. +- **SC-003**: 100% of external create failures leave the internal support request intact, produce explicit operator-visible failure feedback, and write an audit entry for the failed handoff. +- **SC-004**: Authorization tests prove that operators never see or mutate external ticket linkage for a workspace or tenant they are not entitled to, even when they know an internal support reference or external ticket reference. diff --git a/specs/256-external-support-desk-handoff/tasks.md b/specs/256-external-support-desk-handoff/tasks.md new file mode 100644 index 00000000..6a4cddea --- /dev/null +++ b/specs/256-external-support-desk-handoff/tasks.md @@ -0,0 +1,192 @@ +--- + +description: "Task list for External Support Desk / PSA Handoff" + +--- + +# Tasks: External Support Desk / PSA Handoff + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/contracts/external-support-desk-handoff.logical.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/checklists/requirements.md` + +**Support truth**: Spec 246 and the existing repo code remain authoritative, except for one bounded Spec 256 finalization exception: after internal request creation, the same `SupportRequest` row may receive exactly one synchronous write limited to the external handoff fields. Extend `apps/platform/app/Models/SupportRequest.php` and the current support-request submission path only; do not add a second support-ticket entity, support queue, support register, or support-request resource. +**Tests (TEST-GOV-001)**: REQUIRED (Pest) for all runtime behavior changes in this slice. Keep proof in focused unit plus feature lanes only, then run the narrow manual smoke path from `quickstart.md`. +**Operations**: This slice must stay synchronous inside the existing support-request path. Do not create, queue, resume, or complete an `OperationRun`. +**RBAC**: Workspace membership and tenant entitlement remain `404` boundaries; in-scope members missing `Capabilities::SUPPORT_REQUESTS_CREATE` remain `403`; latest-handoff visibility must follow the same boundary. +**Provider boundary**: One configured external desk target only. No helpdesk registry, no target-management UI, and no multi-provider framework in this slice. +**Organization**: Tasks are grouped by user story so create, link, and explicit-failure behavior can be implemented and validated independently once the shared foundation exists. + +## Phase 1: Setup (Shared Preparation) + +**Purpose**: Lock the bounded repo-grounded scope before runtime work begins. + +- [x] T001 Review `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/contracts/external-support-desk-handoff.logical.openapi.yaml`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/checklists/requirements.md` and confirm the slice stays one-way, single-target, and SupportRequest-backed. +- [x] T002 [P] Verify the exact reuse seams from Spec 246 in `apps/platform/app/Models/SupportRequest.php`, `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php`, `apps/platform/app/Support/SupportRequests/SupportRequestContextBuilder.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, and the new app-config seam `apps/platform/config/support_desk.php` before adding any new handoff behavior. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the bounded persistence, target-resolution, audit, and shared summary seams that every story depends on. + +**Critical**: No user story work should begin until this phase is complete. + +- [x] T003 Extend the existing support-request truth with `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary` in `apps/platform/database/migrations/*_add_external_handoff_fields_to_support_requests_table.php`, `apps/platform/app/Models/SupportRequest.php`, and `apps/platform/database/factories/SupportRequestFactory.php` without creating a second support-ticket model or table. +- [x] T004 [P] Add the one concrete provider-owned handoff seam and the single-target app-config contract in `apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php` and `apps/platform/config/support_desk.php`, enforce the five-second outbound timeout there, and avoid introducing a support settings UI, provider registry, or generic helpdesk framework. +- [x] T005 [P] Preserve the existing `support_request.created` audit path and add stable audit action IDs plus bounded audit payload helpers for external ticket created, external ticket linked, and external handoff failed in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`. +- [x] T006 Add one shared latest-handoff summary read path for tenant and run primary contexts in `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` so both existing support actions reuse the same scoped query, naming, and no-cross-scope-shortcut rules. + +**Checkpoint**: Foundation ready. The existing support-request path can now persist neutral external-linkage truth, resolve one target, and read the latest scoped handoff summary. + +--- + +## Phase 3: User Story 1 - Create A New External Ticket From The Existing Support Flow (Priority: P1) 🎯 MVP + +**Goal**: An entitled operator can submit the existing support action and create one external desk ticket from the current tenant or run context without leaving the product. + +**Independent Test**: Submit `Request support` from the tenant dashboard and the operation-run viewer with `create_external_ticket`, fake one configured target, and verify the same `SupportRequest` row keeps the internal `SR-...` reference while storing the returned external reference and URL. + +### Tests for User Story 1 + +- [x] T007 [P] [US1] Add unit coverage for `create_external_ticket` branching, single-target availability fallback, the five-second timeout path, and created-ticket reference or URL normalization in `apps/platform/tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php`. +- [x] T008 [P] [US1] Add feature coverage for tenant and run `create_external_ticket` success paths in `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php` and `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php`. + +### Implementation for User Story 1 + +- [x] T009 [US1] Extend `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` so internal support-request creation commits first, `create_external_ticket` runs synchronously afterward, and the one allowed Spec 256 finalization write records the external reference or URL back onto the same `SupportRequest` row. +- [x] T010 [US1] Extend the tenant dashboard support action in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php` with handoff-mode choice, target-availability guidance, `TenantPilot only` versus `TenantPilot + external support desk` mutation-scope copy, latest-handoff summary copy, and success feedback that shows both internal and external references when a ticket is created. +- [x] T011 [US1] Extend the run-context support action in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php` with the same create flow, scoped latest-handoff summary, `TenantPilot only` versus `TenantPilot + external support desk` mutation-scope copy, and success feedback without adding a new run-support surface. +- [x] T012 [US1] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php` and fix any create-path regressions before moving to the link flow. + +**Checkpoint**: User Story 1 is independently functional when both existing support actions can create one external ticket and immediately show the persisted linkage on the same support-request truth. + +--- + +## Phase 4: User Story 2 - Link An Already-Existing External Ticket During Support Submission (Priority: P1) + +**Goal**: An entitled operator can record an external ticket that already exists without creating a duplicate external case. + +**Independent Test**: Submit the existing tenant and run `Request support` actions with `link_existing_ticket`, provide a valid reference and optional URL, and verify the `SupportRequest` stores that linkage without issuing an external create call. + +### Tests for User Story 2 + +- [x] T013 [P] [US2] Add unit coverage for `link_existing_ticket` reference normalization, optional URL normalization, and invalid-link rejection in `apps/platform/tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php`. +- [x] T014 [P] [US2] Add feature coverage for tenant and run `link_existing_ticket` submissions, including the no-create-call guarantee and linked-flow success feedback that shows both references, in `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php` and `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php`. + +### Implementation for User Story 2 + +- [x] T015 [US2] Implement `link_existing_ticket` branching, conditional validation, and persisted external reference or URL behavior in `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` and `apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php`. +- [x] T016 [US2] Add conditional external reference and URL inputs plus linked-flow success feedback that shows the internal and external references on the tenant dashboard action in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php`. +- [x] T017 [US2] Add the same link controls plus linked-flow success feedback to the run-context action in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php`. +- [x] T018 [US2] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php` and fix any link-path regressions before moving to failure hardening. + +**Checkpoint**: User Story 2 is independently functional when both existing support actions can link an already-created external ticket without producing a duplicate external create call. + +--- + +## Phase 5: User Story 3 - Keep Failures Explicit, Scoped, And Auditable (Priority: P2) + +**Goal**: External handoff failure remains visible and auditable while the internal support request stays durable and the same tenant or run contexts stay the only visibility surfaces. + +**Independent Test**: Force `create_external_ticket` to fail after internal request creation, then verify the internal `SupportRequest` remains persisted, the current support context shows the latest failure summary for that same support reference, and audit plus authorization behavior stays correct. + +### Tests for User Story 3 + +- [x] T019 [P] [US3] Add unit coverage for latest-handoff summary derivation, latest-per-context selection, and persisted failure-summary semantics in `apps/platform/tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php`. +- [x] T020 [P] [US3] Add feature coverage for tenant and run failed-create partial-success behavior, including timeout-normalized failure feedback, in `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php` and `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php`. +- [x] T021 [P] [US3] Add feature coverage for `404` versus `403` boundaries and context-scoped latest-summary visibility in `apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php`. +- [x] T022 [P] [US3] Add feature coverage for preserved `support_request.created` auditing plus created, linked, and failed external-handoff audit events in `apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php`. + +### Implementation for User Story 3 + +- [x] T023 [US3] Persist bounded `external_handoff_failure_summary` semantics, the one allowed Spec 256 finalization-write contract, and latest-summary scoping rules in `apps/platform/app/Models/SupportRequest.php` and `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` without adding support history pages, external-reference lookup routes, or a second support product surface. +- [x] T024 [US3] Implement explicit partial-success or warning feedback plus revisit-time failure-summary rendering in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php`. +- [x] T025 [US3] Enforce the shared authorization and audit boundary for create, link, failure, and latest-summary visibility in `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` without introducing queues, retries, or `OperationRun` orchestration. +- [x] T026 [US3] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php` and fix any failure, audit, or authorization regressions before final polish. + +**Checkpoint**: User Story 3 is independently functional when explicit failure truth, scoped visibility, and audit coverage all hold without losing the internal support request. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Close the slice without widening scope, and leave a clean validation and guardrail trail for review. + +- [x] T027 Confirm `Request support`, `Support reference`, `External ticket`, handoff-mode labels, mutation-scope wording, and latest-summary copy stay aligned across `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php` without leaking provider-specific product names into primary operator copy. +- [x] T028 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` on the touched platform files before final validation. +- [x] T029 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php` as the focused unit close-out suite from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md`. +- [x] T030 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php` as the focused feature close-out suite from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md`. +- [x] T031 Execute the manual smoke path in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md` for tenant and run create, link, and failure handling, including the no-new-support-surface and no-`OperationRun` checks. Completed through a temporary Pest Browser smoke harness covering tenant create, run link, run failure, latest failure summary, no console errors, and no persistent browser-test surface. +- [x] T032 Record the final implementation close-out in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/plan.md`, including the guardrail outcome and any explicit `document-in-feature` or named `follow-up-spec` decision for target configuration, retry pressure, or multi-provider pressure instead of hiding that scope in code review. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Phase 1 starts immediately. +- Phase 2 depends on Phase 1 and blocks all story work. +- Phase 3 depends on Phase 2 and delivers the MVP create flow. +- Phase 4 depends on Phase 2 and is safest after Phase 3 because it extends the same submission service and the same two action forms. +- Phase 5 depends on Phases 3 and 4 because failure, visibility, and audit proof must cover both create and link behavior on both existing surfaces. +- Phase 6 depends on every prior phase. + +### User Story Dependencies + +- US1 is the MVP and first shippable increment. +- US2 is independently testable but should follow US1 because both stories extend the same `SupportRequestSubmissionService` and support-action forms. +- US3 depends on US1 and US2 because explicit failure, audit, and scoped-visibility rules must cover every handoff mode. + +### Within Each User Story + +- Write the listed Pest coverage first and ensure it fails before implementation. +- Land shared submission-service changes before surface wiring whenever both are required. +- Re-run the story-specific validation task before moving to the next story. + +--- + +## Parallel Opportunities + +### Phase 1 + +- T001 and T002 can run in parallel. + +### Phase 2 + +- T004 and T005 can run in parallel after T003 establishes the persisted handoff fields. + +### User Story 1 + +- T007 and T008 can run in parallel before implementation work starts. + +### User Story 2 + +- T013 and T014 can run in parallel before implementation work starts. + +### User Story 3 + +- T019, T020, T021, and T022 can run in parallel before the failure hardening pass. + +--- + +## Implementation Strategy + +### MVP First + +1. Complete Phase 1. +2. Complete Phase 2. +3. Complete Phase 3. +4. Stop and review the create-only external handoff slice before adding link and failure hardening. + +### Incremental Delivery + +1. Ship US1 so the product can create one external ticket from the two existing support-aware surfaces. +2. Add US2 so operators can link an already-opened external ticket without duplicate create behavior. +3. Add US3 so failure honesty, scoped visibility, and audit proof hold across every handoff mode. + +### Team Strategy + +1. Finish Phase 2 together before splitting story work. +2. Parallelize test authoring inside each story. +3. Sequence merges carefully around `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, and `apps/platform/lang/en/localization.php` plus `apps/platform/lang/de/localization.php`, because every story touches those same shared seams. -- 2.45.2 From 905b595880b197fb998b745e53cb47b25965fed9 Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 29 Apr 2026 22:44:27 +0000 Subject: [PATCH 32/36] =?UTF-8?q?chore(sync):=20platform-dev=20=E2=86=92?= =?UTF-8?q?=20dev=20(#306)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automatisch erstellter PR: Synchronisiere `platform-dev` nach `dev`. Enthält alle Änderungen, die aktuell in `platform-dev` vorhanden sind. Bitte Review und Merge gegen `dev`. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/306 --- .../Pages/Findings/FindingsIntakeQueue.php | 35 +- .../Pages/Findings/MyFindingsInbox.php | 35 +- .../Pages/Governance/GovernanceInbox.php | 34 +- .../Monitoring/FindingExceptionsQueue.php | 41 ++- .../Pages/Reviews/CustomerReviewWorkspace.php | 67 +++- .../GovernanceInboxSectionBuilder.php | 235 +++++++++++-- .../Navigation/CanonicalNavigationContext.php | 21 ++ .../governance/governance-inbox.blade.php | 4 +- ...ndingsIntakeQueueNavigationContextTest.php | 58 ++++ .../MyFindingsInboxNavigationContextTest.php | 56 +++ ...eInboxNavigationContextConvergenceTest.php | 54 +++ .../Governance/GovernanceInboxPageTest.php | 73 +++- ...ngExceptionsQueueNavigationContextTest.php | 85 +++++ ...erReviewWorkspaceNavigationContextTest.php | 61 ++++ .../CanonicalNavigationContextTest.php | 23 ++ .../GovernanceInboxSectionBuilderTest.php | 91 ++++- .../checklists/requirements.md | 37 ++ .../plan.md | 254 ++++++++++++++ .../spec.md | 320 ++++++++++++++++++ .../tasks.md | 189 +++++++++++ 20 files changed, 1712 insertions(+), 61 deletions(-) create mode 100644 apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php create mode 100644 apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php create mode 100644 apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php create mode 100644 apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php create mode 100644 apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php create mode 100644 specs/257-governance-decision-convergence/checklists/requirements.md create mode 100644 specs/257-governance-decision-convergence/plan.md create mode 100644 specs/257-governance-decision-convergence/spec.md create mode 100644 specs/257-governance-decision-convergence/tasks.md diff --git a/apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php b/apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php index cb1e37a8..20e538b7 100644 --- a/apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php +++ b/apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php @@ -105,14 +105,26 @@ public function mount(): void protected function getHeaderActions(): array { - return [ - Action::make('clear_tenant_filter') - ->label('Clear tenant filter') - ->icon('heroicon-o-x-mark') + $actions = []; + + $governanceContext = $this->incomingGovernanceContext(); + + if ($governanceContext?->backLinkUrl !== null) { + $actions[] = Action::make('return_to_governance_inbox') + ->label($governanceContext->backLinkLabel ?? 'Back to governance inbox') + ->icon('heroicon-o-arrow-left') ->color('gray') - ->visible(fn (): bool => $this->currentTenantFilterId() !== null) - ->action(fn (): mixed => $this->clearTenantFilter()), - ]; + ->url($governanceContext->backLinkUrl); + } + + $actions[] = 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()); + + return $actions; } public function table(Table $table): Table @@ -698,6 +710,15 @@ private function navigationContext(): CanonicalNavigationContext ); } + private function incomingGovernanceContext(): ?CanonicalNavigationContext + { + $context = CanonicalNavigationContext::fromRequest(request()); + + return $context?->sourceSurface === 'governance.inbox' + ? $context + : null; + } + private function queueUrl(array $overrides = []): string { $resolvedTenant = array_key_exists('tenant', $overrides) diff --git a/apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php b/apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php index e2d547cc..15f9a7b7 100644 --- a/apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php +++ b/apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php @@ -97,14 +97,26 @@ public function mount(): void protected function getHeaderActions(): array { - return [ - Action::make('clear_tenant_filter') - ->label('Clear tenant filter') - ->icon('heroicon-o-x-mark') + $actions = []; + + $governanceContext = $this->incomingGovernanceContext(); + + if ($governanceContext?->backLinkUrl !== null) { + $actions[] = Action::make('return_to_governance_inbox') + ->label($governanceContext->backLinkLabel ?? 'Back to governance inbox') + ->icon('heroicon-o-arrow-left') ->color('gray') - ->visible(fn (): bool => $this->currentTenantFilterId() !== null) - ->action(fn (): mixed => $this->clearTenantFilter()), - ]; + ->url($governanceContext->backLinkUrl); + } + + $actions[] = 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()); + + return $actions; } public function table(Table $table): Table @@ -640,6 +652,15 @@ private function navigationContext(): CanonicalNavigationContext ); } + private function incomingGovernanceContext(): ?CanonicalNavigationContext + { + $context = CanonicalNavigationContext::fromRequest(request()); + + return $context?->sourceSurface === 'governance.inbox' + ? $context + : null; + } + private function queueUrl(): string { $tenant = $this->filteredTenant(); diff --git a/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php b/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php index 7069a0ae..dc13ea03 100644 --- a/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php +++ b/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php @@ -75,6 +75,8 @@ class GovernanceInbox extends Page private ?bool $visibleAlertsFamily = null; + private ?bool $visibleFindingExceptionsFamily = null; + public ?int $tenantId = null; public ?string $family = null; @@ -189,12 +191,11 @@ public function pageUrl(array $overrides = []): string public function navigationContext(): CanonicalNavigationContext { - return new CanonicalNavigationContext( - sourceSurface: 'governance.inbox', + return CanonicalNavigationContext::forGovernanceInbox( canonicalRouteName: static::getRouteName(Filament::getPanel('admin')), tenantId: $this->tenantId, - backLinkLabel: 'Back to governance inbox', backLinkUrl: $this->pageUrl(), + familyKey: $this->family, ); } @@ -223,6 +224,7 @@ private function ensureAtLeastOneVisibleFamily(): void if ( $this->hasVisibleOperationsFamily() || $this->visibleFindingTenants() !== [] + || $this->hasVisibleFindingExceptionsFamily() || $this->reviewTenants() !== [] || $this->hasVisibleAlertsFamily() ) { @@ -266,6 +268,27 @@ private function hasVisibleAlertsFamily(): bool return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW); } + private function hasVisibleFindingExceptionsFamily(): bool + { + if (is_bool($this->visibleFindingExceptionsFamily)) { + return $this->visibleFindingExceptionsFamily; + } + + if ($this->authorizedTenants() === []) { + return $this->visibleFindingExceptionsFamily = false; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->visibleFindingExceptionsFamily = false; + } + + return $this->visibleFindingExceptionsFamily = app(WorkspaceCapabilityResolver::class) + ->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE); + } + /** * @return array */ @@ -375,6 +398,7 @@ private function resolveRequestedFamily(): ?string return in_array($family, [ 'assigned_findings', 'intake_findings', + 'finding_exceptions', 'stale_operations', 'alert_delivery_failures', 'review_follow_up', @@ -424,6 +448,7 @@ private function inboxPayload(): array visibleFindingTenants: $this->visibleFindingTenants(), reviewTenants: $this->reviewTenants(), canViewAlerts: $this->hasVisibleAlertsFamily(), + canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(), selectedTenant: $this->selectedTenant(), selectedFamily: $this->family, navigationContext: $this->navigationContext(), @@ -458,6 +483,7 @@ private function unfilteredInboxPayload(): array visibleFindingTenants: $this->visibleFindingTenants(), reviewTenants: $this->reviewTenants(), canViewAlerts: $this->hasVisibleAlertsFamily(), + canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(), selectedTenant: null, selectedFamily: null, navigationContext: $this->navigationContext(), @@ -491,4 +517,4 @@ private function tenantFilterAloneExcludesRows(): bool return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0; } -} \ No newline at end of file +} diff --git a/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php b/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php index 57d9d349..a5281933 100644 --- a/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php +++ b/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php @@ -208,6 +208,16 @@ protected function getHeaderActions(): array returnActionName: 'operate_hub_return_finding_exceptions', ); + $governanceContext = $this->incomingGovernanceContext(); + + if ($governanceContext?->backLinkUrl !== null) { + $actions[] = Action::make('return_to_governance_inbox') + ->label($governanceContext->backLinkLabel ?? 'Back to governance inbox') + ->icon('heroicon-o-arrow-left') + ->color('gray') + ->url($governanceContext->backLinkUrl); + } + $actions[] = Action::make('clear_filters') ->label('Clear filters') ->icon('heroicon-o-x-mark') @@ -479,7 +489,10 @@ public function selectedExceptionUrl(): ?string return null; } - return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant); + return $this->appendQuery( + FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant), + $this->navigationContext()?->toQuery() ?? [], + ); } public function selectedFindingUrl(): ?string @@ -490,7 +503,10 @@ public function selectedFindingUrl(): ?string return null; } - return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant); + return $this->appendQuery( + FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant), + $this->navigationContext()?->toQuery() ?? [], + ); } public function clearSelectedException(): void @@ -654,6 +670,15 @@ private function navigationContext(): ?CanonicalNavigationContext return CanonicalNavigationContext::fromRequest(request()); } + private function incomingGovernanceContext(): ?CanonicalNavigationContext + { + $context = $this->navigationContext(); + + return $context?->sourceSurface === 'governance.inbox' + ? $context + : null; + } + private function normalizeSelectedFindingExceptionId(): void { if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) { @@ -783,4 +808,16 @@ private function governanceWarningColor(FindingException $record): string return 'danger'; } + + /** + * @param array $query + */ + private function appendQuery(string $url, array $query): string + { + if ($query === []) { + return $url; + } + + return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query); + } } diff --git a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php index 520f4c52..092f4f6f 100644 --- a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php +++ b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php @@ -15,6 +15,7 @@ use App\Support\Auth\Capabilities; use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Filament\TablePaginationProfiles; +use App\Support\Navigation\CanonicalNavigationContext; use App\Support\ReviewPackStatus; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; @@ -112,16 +113,28 @@ public function mount(): void protected function getHeaderActions(): array { - return [ - Action::make('clear_filters') - ->label(__('localization.review.clear_filters')) - ->icon('heroicon-o-x-mark') + $actions = []; + + $governanceContext = $this->incomingGovernanceContext(); + + if ($governanceContext?->backLinkUrl !== null) { + $actions[] = Action::make('return_to_governance_inbox') + ->label($governanceContext->backLinkLabel ?? 'Back to governance inbox') + ->icon('heroicon-o-arrow-left') ->color('gray') - ->visible(fn (): bool => $this->hasActiveFilters()) - ->action(function (): void { - $this->clearWorkspaceFilters(); - }), - ]; + ->url($governanceContext->backLinkUrl); + } + + $actions[] = Action::make('clear_filters') + ->label(__('localization.review.clear_filters')) + ->icon('heroicon-o-x-mark') + ->color('gray') + ->visible(fn (): bool => $this->hasActiveFilters()) + ->action(function (): void { + $this->clearWorkspaceFilters(); + }); + + return $actions; } public function table(Table $table): Table @@ -348,9 +361,13 @@ private function latestReviewUrl(Tenant $tenant): ?string return null; } - return TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant').'?'.http_build_query([ - self::DETAIL_CONTEXT_QUERY_KEY => 1, - ]); + return $this->appendQuery( + TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'), + array_replace( + [self::DETAIL_CONTEXT_QUERY_KEY => 1], + $this->navigationContext()?->toQuery() ?? [], + ), + ); } private function latestReviewPack(Tenant $tenant): ?ReviewPack @@ -527,4 +544,30 @@ private function reviewPackAvailability(Tenant $tenant): string return __('localization.review.available'); } + + private function navigationContext(): ?CanonicalNavigationContext + { + return CanonicalNavigationContext::fromRequest(request()); + } + + private function incomingGovernanceContext(): ?CanonicalNavigationContext + { + $context = $this->navigationContext(); + + return $context?->sourceSurface === 'governance.inbox' + ? $context + : null; + } + + /** + * @param array $query + */ + private function appendQuery(string $url, array $query): string + { + if ($query === []) { + return $url; + } + + return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query); + } } diff --git a/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php b/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php index bc8e778d..bc91953a 100644 --- a/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php +++ b/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php @@ -6,14 +6,15 @@ use App\Filament\Pages\Findings\FindingsIntakeQueue; use App\Filament\Pages\Findings\MyFindingsInbox; +use App\Filament\Pages\Monitoring\FindingExceptionsQueue; use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Resources\AlertDeliveryResource; use App\Filament\Resources\FindingExceptionResource; use App\Filament\Resources\FindingResource; -use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantReviewResource; use App\Models\AlertDelivery; use App\Models\Finding; +use App\Models\FindingException; use App\Models\OperationRun; use App\Models\Tenant; use App\Models\TenantTriageReview; @@ -21,14 +22,12 @@ use App\Models\Workspace; use App\Services\TenantReviews\TenantReviewRegisterService; use App\Support\Auth\Capabilities; -use App\Support\BackupHealth\TenantBackupHealthAssessment; use App\Support\BackupHealth\TenantBackupHealthResolver; use App\Support\Navigation\CanonicalNavigationContext; use App\Support\OperationRunLinks; use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\TenantTriageReviewStateResolver; use App\Support\RestoreSafety\RestoreSafetyResolver; -use App\Support\Tenants\TenantRecoveryTriagePresentation; use Illuminate\Support\Str; final readonly class GovernanceInboxSectionBuilder @@ -41,6 +40,7 @@ private const FAMILY_ORDER = [ 'assigned_findings', 'intake_findings', + 'finding_exceptions', 'stale_operations', 'alert_delivery_failures', 'review_follow_up', @@ -71,6 +71,7 @@ public function build( array $visibleFindingTenants, array $reviewTenants, bool $canViewAlerts, + bool $canViewFindingExceptions = false, ?Tenant $selectedTenant = null, ?string $selectedFamily = null, ?CanonicalNavigationContext $navigationContext = null, @@ -113,6 +114,22 @@ public function build( } if ($authorizedTenantsById !== []) { + if ($canViewFindingExceptions) { + $findingExceptionsSection = $this->findingExceptionsSection( + workspace: $workspace, + authorizedTenants: $authorizedTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$findingExceptionsSection['key']] = $findingExceptionsSection; + $availableFamilies[] = [ + 'key' => $findingExceptionsSection['key'], + 'label' => $findingExceptionsSection['label'], + 'count' => $findingExceptionsSection['count'], + ]; + $familyCounts[$findingExceptionsSection['key']] = $findingExceptionsSection['count']; + } + $operationsSection = $this->operationsSection( workspace: $workspace, authorizedTenants: $authorizedTenantsById, @@ -191,6 +208,59 @@ public function build( ]; } + /** + * @param array $authorizedTenants + * @return array + */ + private function findingExceptionsSection( + Workspace $workspace, + array $authorizedTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $baseQuery = $this->findingExceptionsQuery($workspace, $authorizedTenants, $selectedTenant); + $count = (clone $baseQuery)->count(); + $pendingCount = (clone $baseQuery) + ->where('status', FindingException::STATUS_PENDING) + ->count(); + $expiringCount = (clone $baseQuery) + ->where('current_validity_state', FindingException::VALIDITY_EXPIRING) + ->count(); + $lapsedCount = (clone $baseQuery) + ->where('status', '!=', FindingException::STATUS_PENDING) + ->whereIn('current_validity_state', [ + FindingException::VALIDITY_EXPIRED, + FindingException::VALIDITY_MISSING_SUPPORT, + ]) + ->count(); + $entries = $this->orderedFindingExceptionsQuery(clone $baseQuery) + ->limit(self::PREVIEW_LIMIT) + ->get() + ->map(fn (FindingException $exception): array => $this->findingExceptionEntry($exception, $navigationContext)) + ->all(); + + return [ + 'key' => 'finding_exceptions', + 'label' => 'Finding exceptions', + 'count' => $count, + 'summary' => $this->findingExceptionsSummary($count, $pendingCount, $expiringCount, $lapsedCount), + 'dominant_action_label' => 'Open finding exceptions', + 'dominant_action_url' => $this->appendQuery( + FindingExceptionsQueue::getUrl( + panel: 'admin', + parameters: array_filter([ + 'tenant' => $selectedTenant?->external_id, + ], static fn (mixed $value): bool => is_string($value) && $value !== ''), + ), + $navigationContext?->toQuery() ?? [], + ), + 'entries' => $entries, + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No finding exceptions match this tenant filter right now.' + : 'No finding exceptions need review right now.', + ]; + } + /** * @param array $tenants * @return array @@ -477,28 +547,10 @@ private function reviewFollowUpSection( 'label' => 'Review follow-up', 'count' => count($rawEntries), 'summary' => $this->reviewSummary($followUpCount, $changedCount), - 'dominant_action_label' => 'Open review follow-up', + 'dominant_action_label' => 'Open customer review workspace', 'dominant_action_url' => $selectedTenant instanceof Tenant ? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? []) - : $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive( - $navigationContext?->toQuery() ?? [], - [ - 'backup_posture' => [ - TenantBackupHealthAssessment::POSTURE_ABSENT, - TenantBackupHealthAssessment::POSTURE_STALE, - TenantBackupHealthAssessment::POSTURE_DEGRADED, - ], - 'recovery_evidence' => [ - TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED, - TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED, - ], - 'review_state' => [ - TenantTriageReview::STATE_FOLLOW_UP_NEEDED, - TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW, - ], - 'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST, - ], - )), + : $this->appendQuery(CustomerReviewWorkspace::getUrl(panel: 'admin'), $navigationContext?->toQuery() ?? []), 'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT), 'empty_state' => $selectedTenant instanceof Tenant ? 'No review follow-up is visible for this tenant filter right now.' @@ -634,6 +686,62 @@ private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?Te }); } + /** + * @param array $authorizedTenants + */ + private function findingExceptionsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + $tenantIds = $selectedTenant instanceof Tenant + ? [(int) $selectedTenant->getKey()] + : array_keys($authorizedTenants); + + return FindingException::query() + ->with([ + 'tenant', + 'requester:id,name', + 'owner:id,name', + 'finding' => fn ($query) => $query->withSubjectDisplayName(), + ]) + ->where('workspace_id', (int) $workspace->getKey()) + ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) + ->where(function ($query): void { + $query + ->where('status', FindingException::STATUS_PENDING) + ->orWhereIn('status', [ + FindingException::STATUS_EXPIRING, + FindingException::STATUS_EXPIRED, + ]) + ->orWhereIn('current_validity_state', [ + FindingException::VALIDITY_EXPIRING, + FindingException::VALIDITY_EXPIRED, + FindingException::VALIDITY_MISSING_SUPPORT, + ]); + }); + } + + private function orderedFindingExceptionsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder + { + return $query + ->orderByRaw( + "case + when status = ? then 0 + when current_validity_state = ? then 1 + when current_validity_state = ? then 2 + when current_validity_state = ? then 3 + else 4 + end asc", + [ + FindingException::STATUS_PENDING, + FindingException::VALIDITY_EXPIRED, + FindingException::VALIDITY_MISSING_SUPPORT, + FindingException::VALIDITY_EXPIRING, + ], + ) + ->orderByRaw('case when review_due_at is null then 1 else 0 end asc') + ->orderBy('review_due_at') + ->orderByDesc('id'); + } + /** * @return array */ @@ -727,6 +835,52 @@ private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext ]; } + /** + * @return array + */ + private function findingExceptionEntry(FindingException $exception, ?CanonicalNavigationContext $navigationContext): array + { + $findingLabel = $exception->finding?->resolvedSubjectDisplayName() + ?? 'Finding #'.$exception->finding_id; + $sublineParts = array_values(array_filter([ + $exception->owner?->name !== null ? 'Owner: '.$exception->owner->name : null, + FindingExceptionResource::relativeTimeDescription($exception->review_due_at) + ?? FindingExceptionResource::relativeTimeDescription($exception->expires_at), + is_string($exception->request_reason) && $exception->request_reason !== '' + ? $exception->request_reason + : null, + ])); + + return [ + 'family_key' => 'finding_exceptions', + 'source_model' => FindingException::class, + 'source_key' => (string) $exception->getKey(), + 'tenant_id' => $exception->tenant ? (int) $exception->tenant->getKey() : null, + 'tenant_label' => $exception->tenant?->name, + 'headline' => $findingLabel, + 'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts), + 'urgency_rank' => match (true) { + (string) $exception->status === FindingException::STATUS_PENDING => 0, + (string) $exception->current_validity_state === FindingException::VALIDITY_EXPIRED => 1, + (string) $exception->current_validity_state === FindingException::VALIDITY_MISSING_SUPPORT => 2, + (string) $exception->current_validity_state === FindingException::VALIDITY_EXPIRING => 3, + default => 4, + }, + 'status_label' => $this->findingExceptionStatusLabel($exception), + 'destination_url' => $this->appendQuery( + FindingExceptionsQueue::getUrl( + panel: 'admin', + parameters: array_filter([ + 'tenant' => $exception->tenant?->external_id, + 'exception' => (int) $exception->getKey(), + ], static fn (mixed $value): bool => $value !== null && $value !== ''), + ), + $navigationContext?->toQuery() ?? [], + ), + 'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox', + ]; + } + /** * @param array $row * @return array @@ -855,6 +1009,39 @@ private function alertsSummary(int $count): string ); } + private function findingExceptionsSummary(int $count, int $pendingCount, int $expiringCount, int $lapsedCount): string + { + if ($count === 0) { + return 'No finding exceptions need review in the current scope.'; + } + + return sprintf( + '%d finding exception%s need review. %d pending, %d expiring, and %d lapsed or missing support.', + $count, + $count === 1 ? '' : 's', + $pendingCount, + $expiringCount, + $lapsedCount, + ); + } + + private function findingExceptionStatusLabel(FindingException $exception): string + { + if ((string) $exception->status === FindingException::STATUS_PENDING) { + return 'Pending'; + } + + if (in_array((string) $exception->current_validity_state, [ + FindingException::VALIDITY_EXPIRING, + FindingException::VALIDITY_EXPIRED, + FindingException::VALIDITY_MISSING_SUPPORT, + ], true)) { + return Str::of((string) $exception->current_validity_state)->replace('_', ' ')->title()->value(); + } + + return Str::of((string) $exception->status)->replace('_', ' ')->title()->value(); + } + private function reviewSummary(int $followUpCount, int $changedCount): string { $total = $followUpCount + $changedCount; @@ -885,4 +1072,4 @@ private function appendQuery(string $url, array $query): string return $url.$separator.http_build_query($query); } -} \ No newline at end of file +} diff --git a/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php b/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php index 4f4c80a4..66b33c15 100644 --- a/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php +++ b/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php @@ -18,6 +18,7 @@ public function __construct( public string $sourceSurface, public string $canonicalRouteName, public ?int $tenantId = null, + public ?string $familyKey = null, public ?string $backLinkLabel = null, public ?string $backLinkUrl = null, public array $filterPayload = [], @@ -56,12 +57,31 @@ public static function fromRequest(Request $request): ?self sourceSurface: $sourceSurface, canonicalRouteName: $canonicalRouteName, tenantId: is_numeric($tenantId) ? (int) $tenantId : null, + familyKey: is_string($payload['family_key'] ?? null) && (string) $payload['family_key'] !== '' + ? (string) $payload['family_key'] + : null, backLinkLabel: is_string($payload['back_label'] ?? null) ? (string) $payload['back_label'] : null, backLinkUrl: is_string($payload['back_url'] ?? null) ? (string) $payload['back_url'] : null, filterPayload: [], ); } + public static function forGovernanceInbox( + string $canonicalRouteName, + ?int $tenantId, + ?string $familyKey, + string $backLinkUrl, + ): self { + return new self( + sourceSurface: 'governance.inbox', + canonicalRouteName: $canonicalRouteName, + tenantId: $tenantId, + familyKey: $familyKey, + backLinkLabel: 'Back to governance inbox', + backLinkUrl: $backLinkUrl, + ); + } + /** * @return array */ @@ -117,6 +137,7 @@ private function navPayload(): array 'source_surface' => $this->sourceSurface, 'canonical_route_name' => $this->canonicalRouteName, 'tenant_id' => $this->tenantId, + 'family_key' => $this->familyKey, 'back_label' => $this->backLinkLabel, 'back_url' => $this->backLinkUrl, ], static fn (mixed $value): bool => $value !== null && $value !== ''); diff --git a/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php b/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php index 6280e61e..2a6c5af9 100644 --- a/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php +++ b/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php @@ -18,7 +18,7 @@

      - This workspace decision surface routes you into the existing findings, operations, alerts, and review surfaces without introducing a second workflow state. + This workspace decision surface routes you into the existing findings, finding exceptions, operations, alerts, and review surfaces without introducing a second workflow state.

      @@ -161,4 +161,4 @@ class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm fo @endforeach @endif - \ No newline at end of file + diff --git a/apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php b/apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php new file mode 100644 index 00000000..ba5b2476 --- /dev/null +++ b/apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php @@ -0,0 +1,58 @@ +create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner'); + + $finding = Finding::factory() + ->for($tenant) + ->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'assignee_user_id' => null, + 'status' => Finding::STATUS_NEW, + 'subject_external_id' => 'intake-from-governance', + ]); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $context = CanonicalNavigationContext::forGovernanceInbox( + canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')), + tenantId: (int) $tenant->getKey(), + familyKey: 'intake_findings', + backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [ + 'tenant_id' => (string) $tenant->getKey(), + 'family' => 'intake_findings', + ]), + ); + + Livewire::withQueryParams(array_replace($context->toQuery(), [ + 'tenant' => (string) $tenant->external_id, + 'view' => 'needs_triage', + ])) + ->actingAs($user) + ->test(FindingsIntakeQueue::class) + ->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey()) + ->assertActionVisible('return_to_governance_inbox') + ->assertCanSeeTableRecords([$finding]) + ->assertSee('Shared unassigned work') + ->assertDontSee('This workspace decision surface routes you'); +}); diff --git a/apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php b/apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php new file mode 100644 index 00000000..41647011 --- /dev/null +++ b/apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php @@ -0,0 +1,56 @@ +create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner'); + + Finding::factory() + ->for($tenant) + ->assignedTo((int) $user->getKey()) + ->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'subject_external_id' => 'assigned-from-governance', + ]); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $context = CanonicalNavigationContext::forGovernanceInbox( + canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')), + tenantId: (int) $tenant->getKey(), + familyKey: 'assigned_findings', + backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [ + 'tenant_id' => (string) $tenant->getKey(), + 'family' => 'assigned_findings', + ]), + ); + + Livewire::withQueryParams(array_replace($context->toQuery(), [ + 'tenant' => (string) $tenant->external_id, + ])) + ->actingAs($user) + ->test(MyFindingsInbox::class) + ->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey()) + ->assertActionVisible('return_to_governance_inbox') + ->assertCanSeeTableRecords([Finding::query()->where('subject_external_id', 'assigned-from-governance')->firstOrFail()]) + ->assertSee('Assigned to me only') + ->assertDontSee('This workspace decision surface routes you'); +}); diff --git a/apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php b/apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php new file mode 100644 index 00000000..7b466d79 --- /dev/null +++ b/apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php @@ -0,0 +1,54 @@ +create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'manager'); + + $finding = Finding::factory() + ->for($tenant) + ->riskAccepted() + ->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'subject_external_id' => 'governance-exception-lane', + ]); + + $exception = FindingException::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_id' => (int) $finding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => FindingException::STATUS_PENDING, + 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, + 'request_reason' => 'Governance convergence request', + 'requested_at' => now()->subDay(), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + + $response = $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $tenant->getKey().'&family=finding_exceptions'); + + $response->assertOk() + ->assertSee('Finding exceptions') + ->assertSee('Open finding exceptions') + ->assertSee('Governance convergence request') + ->assertSee('nav%5Bfamily_key%5D=finding_exceptions', false) + ->assertSee('nav%5Bback_url%5D='.urlencode(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $tenant->getKey().'&family=finding_exceptions'), false) + ->assertSee('exception='.(string) $exception->getKey(), false) + ->assertDontSee('Open my findings') + ->assertDontSee('Open findings intake'); +}); diff --git a/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php b/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php index 4f3a33a0..52610a5d 100644 --- a/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php +++ b/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php @@ -5,6 +5,7 @@ use App\Filament\Pages\Governance\GovernanceInbox; use App\Models\AlertDelivery; use App\Models\Finding; +use App\Models\FindingException; use App\Models\OperationRun; use App\Models\Tenant; use App\Models\TenantTriageReview; @@ -46,6 +47,28 @@ ->reopened() ->create(); + $exceptionFinding = Finding::factory() + ->for($alphaTenant) + ->riskAccepted() + ->create([ + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'subject_external_id' => 'exception-governance-home', + ]); + + FindingException::query()->create([ + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'tenant_id' => (int) $alphaTenant->getKey(), + 'finding_id' => (int) $exceptionFinding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => FindingException::STATUS_PENDING, + 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, + 'request_reason' => 'Governance home exception review', + 'requested_at' => now()->subDay(), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + OperationRun::factory() ->forTenant($alphaTenant) ->create([ @@ -87,13 +110,15 @@ ->assertOk() ->assertSee('Assigned findings') ->assertSee('Findings intake') + ->assertSee('Finding exceptions') ->assertSee('Operations follow-up') ->assertSee('Alert delivery failures') ->assertSee('Review follow-up') ->assertSee('Open my findings') + ->assertSee('Open finding exceptions') ->assertSee('Open terminal follow-up') ->assertSee('Open alert deliveries') - ->assertSee('Open review follow-up'); + ->assertSee('Open customer review workspace'); }); it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void { @@ -140,4 +165,48 @@ ->assertSee('Alert delivery failures') ->assertSee('No failed alert deliveries match this tenant filter right now.') ->assertDontSee('Open my findings'); -}); \ No newline at end of file +}); + +it('omits the finding exceptions lane when the workspace capability is not visible', function (): void { + $tenant = Tenant::factory()->create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'readonly'); + + Finding::factory() + ->for($tenant) + ->assignedTo((int) $user->getKey()) + ->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + $exceptionFinding = Finding::factory() + ->for($tenant) + ->riskAccepted() + ->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + FindingException::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_id' => (int) $exceptionFinding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => FindingException::STATUS_PENDING, + 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, + 'request_reason' => 'Hidden exception lane', + 'requested_at' => now()->subDay(), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin')) + ->assertOk() + ->assertSee('Assigned findings') + ->assertDontSee('Finding exceptions') + ->assertDontSee('Hidden exception lane'); +}); diff --git a/apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php b/apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php new file mode 100644 index 00000000..a0ef2c0b --- /dev/null +++ b/apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php @@ -0,0 +1,85 @@ +create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'manager'); + + $finding = Finding::factory() + ->for($tenant) + ->riskAccepted() + ->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'subject_external_id' => 'exception-secondary-finding', + ]); + + $exception = FindingException::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_id' => (int) $finding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => FindingException::STATUS_PENDING, + 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, + 'request_reason' => 'Exception queue return context', + 'requested_at' => now()->subDay(), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $context = CanonicalNavigationContext::forGovernanceInbox( + canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')), + tenantId: (int) $tenant->getKey(), + familyKey: 'finding_exceptions', + backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [ + 'tenant_id' => (string) $tenant->getKey(), + 'family' => 'finding_exceptions', + ]), + ); + + $component = Livewire::withQueryParams(array_replace($context->toQuery(), [ + 'tenant' => (string) $tenant->external_id, + 'exception' => (int) $exception->getKey(), + ])) + ->actingAs($user) + ->test(FindingExceptionsQueue::class) + ->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey()) + ->assertSet('selectedFindingExceptionId', (int) $exception->getKey()) + ->assertActionVisible('return_to_governance_inbox') + ->assertActionVisible('open_selected_exception') + ->assertActionVisible('open_selected_finding') + ->assertSee('Exception queue return context') + ->assertSee('Focused review lane') + ->assertDontSee('This workspace decision surface routes you'); + + expect($component->instance()->selectedExceptionUrl()) + ->toContain('nav%5Bsource_surface%5D=governance.inbox') + ->toContain('nav%5Bfamily_key%5D=finding_exceptions') + ->toContain('nav%5Btenant_id%5D='.(string) $tenant->getKey()); + + expect($component->instance()->selectedFindingUrl()) + ->toContain('nav%5Bsource_surface%5D=governance.inbox') + ->toContain('nav%5Bfamily_key%5D=finding_exceptions') + ->toContain('nav%5Btenant_id%5D='.(string) $tenant->getKey()); +}); diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php new file mode 100644 index 00000000..230ca340 --- /dev/null +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php @@ -0,0 +1,61 @@ +create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + $snapshot = seedTenantReviewEvidence($tenant); + + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $review->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'generated_at' => now(), + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $context = CanonicalNavigationContext::forGovernanceInbox( + canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')), + tenantId: (int) $tenant->getKey(), + familyKey: 'review_follow_up', + backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [ + 'tenant_id' => (string) $tenant->getKey(), + 'family' => 'review_follow_up', + ]), + ); + + Livewire::withQueryParams(array_replace($context->toQuery(), [ + 'tenant' => (string) $tenant->external_id, + ])) + ->actingAs($user) + ->test(CustomerReviewWorkspace::class) + ->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey()) + ->assertActionVisible('return_to_governance_inbox') + ->assertCanSeeTableRecords([$tenant->fresh()]) + ->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $review->fresh()], $tenant), false) + ->assertSee(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY.'=1', false) + ->assertSee('nav%5Bsource_surface%5D=governance.inbox', false) + ->assertSee('nav%5Bfamily_key%5D=review_follow_up', false) + ->assertDontSee('This workspace decision surface routes you'); +}); diff --git a/apps/platform/tests/Unit/Support/CanonicalNavigationContextTest.php b/apps/platform/tests/Unit/Support/CanonicalNavigationContextTest.php index 4ccb2ddb..5e09b839 100644 --- a/apps/platform/tests/Unit/Support/CanonicalNavigationContextTest.php +++ b/apps/platform/tests/Unit/Support/CanonicalNavigationContextTest.php @@ -52,3 +52,26 @@ ->and($context?->backLinkLabel)->toBe('Back to backup set') ->and($context?->backLinkUrl)->toBe('/admin/backup-sets/8'); }); + +it('serializes governance inbox family context for secondary surface return links', function (): void { + $context = CanonicalNavigationContext::forGovernanceInbox( + canonicalRouteName: 'filament.admin.pages.governance.inbox', + tenantId: 12, + familyKey: 'finding_exceptions', + backLinkUrl: '/admin/governance/inbox?tenant_id=12&family=finding_exceptions', + ); + + $roundTrip = CanonicalNavigationContext::fromRequest(Request::create('/admin/finding-exceptions/queue', 'GET', $context->toQuery())); + + expect($context->toQuery()['nav']) + ->toMatchArray([ + 'source_surface' => 'governance.inbox', + 'tenant_id' => 12, + 'family_key' => 'finding_exceptions', + 'back_label' => 'Back to governance inbox', + ]) + ->and($roundTrip?->sourceSurface)->toBe('governance.inbox') + ->and($roundTrip?->tenantId)->toBe(12) + ->and($roundTrip?->familyKey)->toBe('finding_exceptions') + ->and($roundTrip?->backLinkUrl)->toBe('/admin/governance/inbox?tenant_id=12&family=finding_exceptions'); +}); diff --git a/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php b/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php index 68a556de..c03db279 100644 --- a/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php +++ b/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php @@ -4,6 +4,7 @@ use App\Models\AlertDelivery; use App\Models\Finding; +use App\Models\FindingException; use App\Models\OperationRun; use App\Models\Tenant; use App\Models\TenantTriageReview; @@ -54,6 +55,28 @@ 'subject_external_id' => 'intake-finding', ]); + $exceptionFinding = Finding::factory() + ->for($alphaTenant) + ->riskAccepted() + ->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'subject_external_id' => 'exception-finding', + ]); + + FindingException::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $alphaTenant->getKey(), + 'finding_id' => (int) $exceptionFinding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => FindingException::STATUS_PENDING, + 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, + 'request_reason' => 'Needs approval', + 'requested_at' => now()->subDay(), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + OperationRun::factory() ->forTenant($alphaTenant) ->create([ @@ -129,6 +152,7 @@ visibleFindingTenants: [$alphaTenant, $bravoTenant], reviewTenants: [$alphaTenant, $bravoTenant], canViewAlerts: true, + canViewFindingExceptions: true, navigationContext: $context, ); @@ -136,6 +160,7 @@ ->toBe([ 'assigned_findings', 'intake_findings', + 'finding_exceptions', 'stale_operations', 'alert_delivery_failures', 'review_follow_up', @@ -143,6 +168,7 @@ ->and($payload['family_counts'])->toMatchArray([ 'assigned_findings' => 1, 'intake_findings' => 1, + 'finding_exceptions' => 1, 'stale_operations' => 2, 'alert_delivery_failures' => 1, 'review_follow_up' => 2, @@ -153,6 +179,9 @@ expect($sections['assigned_findings']['dominant_action_url']) ->toContain('/admin/findings/my-work') ->toContain('nav%5Bback_label%5D=Back+to+governance+inbox') + ->and($sections['finding_exceptions']['dominant_action_label'])->toBe('Open finding exceptions') + ->and($sections['finding_exceptions']['dominant_action_url'])->toContain('/admin/finding-exceptions/queue') + ->and($sections['finding_exceptions']['entries'][0]['destination_url'])->toContain('exception='.(string) $sections['finding_exceptions']['entries'][0]['source_key']) ->and($sections['stale_operations']['dominant_action_label'])->toBe('Open terminal follow-up') ->and($sections['stale_operations']['dominant_action_url'])->toContain('problemClass=terminal_follow_up') ->and($sections['alert_delivery_failures']['dominant_action_url'])->toContain('tableFilters%5Bstatus%5D%5Bvalue%5D=failed') @@ -196,4 +225,64 @@ ->and($payload['sections'][0]['key'])->toBe('alert_delivery_failures') ->and($payload['sections'][0]['count'])->toBe(0) ->and($payload['sections'][0]['empty_state'])->toContain('tenant filter'); -}); \ No newline at end of file +}); + +it('omits finding exceptions when the exception family is hidden or tenant scope is inaccessible', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + $visibleTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Visible Tenant', + ]); + $hiddenTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Hidden Tenant', + ]); + + $finding = Finding::factory() + ->for($hiddenTenant) + ->riskAccepted() + ->create(['workspace_id' => (int) $workspace->getKey()]); + + FindingException::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $hiddenTenant->getKey(), + 'finding_id' => (int) $finding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => FindingException::STATUS_PENDING, + 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, + 'request_reason' => 'Hidden request', + 'requested_at' => now()->subDay(), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + + $builder = app(GovernanceInboxSectionBuilder::class); + + $payloadWithoutCapability = $builder->build( + user: $user, + workspace: $workspace, + authorizedTenants: [$visibleTenant, $hiddenTenant], + visibleFindingTenants: [], + reviewTenants: [], + canViewAlerts: false, + canViewFindingExceptions: false, + ); + + $payloadWithHiddenTenantOnly = $builder->build( + user: $user, + workspace: $workspace, + authorizedTenants: [$visibleTenant], + visibleFindingTenants: [], + reviewTenants: [], + canViewAlerts: false, + canViewFindingExceptions: true, + ); + + expect(collect($payloadWithoutCapability['available_families'])->pluck('key')->all()) + ->not->toContain('finding_exceptions') + ->and($payloadWithHiddenTenantOnly['family_counts']['finding_exceptions'] ?? null)->toBe(0) + ->and($payloadWithHiddenTenantOnly['sections'])->toBe([]); +}); diff --git a/specs/257-governance-decision-convergence/checklists/requirements.md b/specs/257-governance-decision-convergence/checklists/requirements.md new file mode 100644 index 00000000..6c89cc05 --- /dev/null +++ b/specs/257-governance-decision-convergence/checklists/requirements.md @@ -0,0 +1,37 @@ +# Preparation Review Checklist: Governance Decision Surface Convergence v1 + +**Purpose**: Verify that the preparation package is repo-grounded, narrowly scoped, and ready for a later implementation loop. +**Created**: 2026-04-29 +**Review outcome class**: Workflow Compression +**Workflow outcome**: approve for implementation +**Test-governance outcome**: keep + +## Candidate Selection + +- [x] CHK001 The selected slice is anchored to `docs/product/roadmap.md` (`Decision-Based Operating Foundations`) and the open-gap truth in `docs/product/implementation-ledger.md`. +- [x] CHK002 Already-specced candidates such as Spec 043, Spec 249, Spec 250, Spec 251, Spec 252, Spec 253, Spec 254, Spec 255, and Spec 256 are explicitly excluded from reopening. +- [x] CHK003 The package chooses the smallest repo-grounded slice: extend the existing governance inbox and specialist pages instead of inventing a new shell or workflow engine. + +## Scope And Truth + +- [x] CHK004 The spec, plan, and tasks all state that no new persistence, inbox-item truth, queue state, or mutation lane is introduced. +- [x] CHK005 The governance inbox remains the canonical workspace decision home, while specialist queues and the customer review workspace remain secondary-context surfaces. +- [x] CHK006 The finding-exceptions lane and review-consumption handoff are described as derived from existing repo truth rather than a new abstraction layer. + +## UX And Authorization + +- [x] CHK007 The package makes one dominant next action explicit for the governance home, preserves one dominant default action on each specialist surface, and keeps specialist pages from duplicating the workspace-level summary. +- [x] CHK008 `404` vs `403` semantics are explicit for workspace membership, tenant scope, and no-visible-family cases. +- [x] CHK009 Hidden tenants and hidden families are omitted from counts, labels, previews, and empty-state hints. + +## Test Governance + +- [x] CHK010 Planned proof stays in focused `Unit` plus `Feature` lanes only, with explicit validation commands. +- [x] CHK011 The tasks include explicit coverage for family assembly, arrival/return continuity, latest-published-review preference versus workspace fallback, duplicate-truth prevention, and read-only review integrity on the specialist pages. +- [x] CHK012 The checklist records the active review outcome class and workflow outcome instead of leaving readiness implicit. + +## Readiness Outcome + +- [x] CHK013 The package is ready for implementation only if analysis confirms that the scope remains bounded to existing governance, findings, monitoring, and review seams and that `CustomerReviewWorkspace` stays read-only. +- [x] CHK014 The package explicitly preserves the no-new-Graph-call, no-queue, no-`OperationRun`, and no-new-audit-stream constraints for this slice. +- [x] CHK015 Any broader dashboard-entry, cross-tenant, or workflow-engine follow-up is listed separately rather than hidden inside this slice. diff --git a/specs/257-governance-decision-convergence/plan.md b/specs/257-governance-decision-convergence/plan.md new file mode 100644 index 00000000..9ddc3200 --- /dev/null +++ b/specs/257-governance-decision-convergence/plan.md @@ -0,0 +1,254 @@ +# Implementation Plan: Governance Decision Surface Convergence v1 + +**Branch**: `257-governance-decision-convergence` | **Date**: 2026-04-29 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from [spec.md](spec.md) + +## Summary + +Tighten TenantPilot's decision-first operating model by converging onto the existing `GovernanceInbox` as the canonical workspace decision home, extending it with the still-missing finding-exceptions lane, and aligning the specialist findings, exceptions, and customer-review pages behind one truthful arrival and return model. The slice is intentionally read-only and reuses existing page, builder, and navigation seams instead of adding a new page shell, workflow state, or task engine. + +Filament remains on Livewire v4, no panel-provider registration changes are required (`apps/platform/bootstrap/providers.php` remains authoritative), no globally searchable resource is added, and no new asset bundle is expected. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing governance inbox and navigation helpers +**Storage**: PostgreSQL via existing findings, finding-exceptions, reviews, packs, alerts, and operation-run truth only +**Testing**: Pest v4 `Unit` plus `Feature` coverage +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`) +**Project Type**: Web application (Laravel monolith with Filament pages) +**Performance Goals**: derived DB-only page rendering, no new remote calls, and no queue or `OperationRun` start in v1 +**Constraints**: no new persistence, no new page shell, no new mutation lane, no customer portal scope, no duplicate truth across equal-priority cards +**Scale/Scope**: 1 existing canonical page, 4 specialist page classes plus their Blade views, and 1 bounded section-builder extension + +## Likely Affected Repo Surfaces + +- `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` +- `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` +- `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` +- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` +- `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php` +- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` +- `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php` +- `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php` +- `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php` +- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` +- `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` +- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` +- `apps/platform/app/Support/OperateHub/OperateHubShell.php` +- `apps/platform/app/Support/Badges/BadgeRenderer.php` + +## UI / Filament & Livewire Fit + +- Reuse the existing `GovernanceInbox` Filament page instead of introducing a new page class or a utility shell. +- Keep the governance home as a section-based read-only page. Add one derived exception lane and adjust review-consumption handoff copy and arrival context, but keep diagnostics and proof on the existing specialist or detail routes. +- Preserve specialist-page ownership. `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace` remain the pages where lane-specific truth and existing safe actions live. +- Any state that must survive navigation or Livewire requests stays on public, query-backed, or existing session-backed state. Do not move convergence state into private page properties. +- No new resource, global-search result, or panel asset registration is planned. + +## RBAC / Policy Fit + +- Workspace membership remains the first gate for the governance home and all converged routes. +- Findings lanes continue to reuse `Capabilities::TENANT_FINDINGS_VIEW`; existing inline safe actions such as claim remain on the owning specialist pages and continue to require their existing capabilities such as `Capabilities::TENANT_FINDINGS_ASSIGN`. +- The exception lane must reuse the existing `FindingExceptionsQueue` visibility contract based on `Capabilities::FINDING_EXCEPTION_APPROVE` rather than inventing a second exception-view capability. +- The review-consumption handoff must reuse the current review and review-pack access rules instead of adding a new customer-review-workspace capability family. +- `404` applies to non-members and out-of-scope tenant targets. `403` applies only to in-scope members who still cannot see any converged family. + +## Audit / Logging Fit + +- The convergence layer stays read-only and should not add a new page-view audit stream. +- Existing mutations and downloads remain audited on their current owning surfaces. +- No new `OperationRun`, notification stream, or navigation-event ledger is required. + +## Data & Query Fit + +- Extend `GovernanceInboxSectionBuilder` rather than creating a new persistence or projection layer. +- The new exception lane must derive from existing `FindingException` truth and the current queue semantics, not from a copied workflow summary. +- Review-consumption handoff should keep using the current latest-published-review vs customer-review-workspace fallback logic. +- Family counts, previews, and empty-state decisions must be computed only after tenant and capability filtering, so hidden tenants and hidden lanes do not leak through aggregate counts. +- Keep any new family key local to the page and builder; do not introduce a new domain enum or persisted state family. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament +- **Shared-family relevance**: governance decision home, specialist queues, customer-review routing, navigation continuity +- **State layers in scope**: page, URL-query, table/session restore +- **Audience modes in scope**: operator-MSP, customer-read-only on the existing review workspace +- **Decision/diagnostic/raw hierarchy plan**: decision-first on the governance home, diagnostics-second on specialist pages, raw/support detail remains on existing detail paths only +- **Raw/support gating plan**: hidden by default on the governance home; existing gating remains on source/detail surfaces +- **One-primary-action / duplicate-truth control**: governance home keeps one dominant CTA per section; specialist pages keep their existing lane-owned primary action and must not duplicate the governance-home summary +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: global-context-shell +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none planned +- **Active feature PR close-out entry**: Guardrail / Smoke Coverage + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and the specialist findings, exception, and review pages listed above +- **Shared abstractions reused**: `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and existing source-page action-surface declarations +- **New abstraction introduced? why?**: at most one bounded convergence helper for arrival and return semantics if the existing navigation-context helper needs a thin extension; no new framework or registry is justified +- **Why the existing abstraction was sufficient or insufficient**: existing abstractions are sufficient for page ownership and current routing, but not yet sufficient to make the governance home the single truthful start surface across the missing exception and review-consumption lanes +- **Bounded deviation / spread control**: no new shell or workflow engine; all implementation stays inside existing governance, findings, monitoring, reviews, and navigation seams + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no new `OperationRun` contract change +- **Central contract reused**: the already-existing stale-operations deep-link behavior remains unchanged +- **Delegated UX behaviors**: `N/A` +- **Surface-owned behavior kept local**: the governance home continues to list stale operations through the existing family, but this spec does not change that family's start or completion semantics +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: `N/A` +- **Platform-core seams**: existing governance and navigation vocabulary only +- **Neutral platform terms / contracts preserved**: `Governance inbox`, `finding exceptions`, `customer review workspace`, `Open lane`, and `Back to governance inbox` +- **Retained provider-specific semantics and why**: none new +- **Bounded extraction or follow-up path**: `N/A` + +## Constitution Check + +*GATE: Must pass before implementation preparation continues.* + +- Inventory-first: PASS. All sections remain derived from existing findings, exceptions, reviews, alerts, and operation-run truth. +- Read/write separation: PASS. The convergence layer remains read-only and keeps mutations on existing source surfaces. +- Graph contract path: PASS. No new Graph or provider calls are introduced. +- Deterministic capabilities: PASS. Existing capability registries remain authoritative. +- Workspace and tenant isolation: PASS. Workspace membership remains first, and tenant/family omission happens before counts are exposed. +- RBAC-UX plane separation: PASS. Everything stays in `/admin`; no `/system` expansion. +- Destructive action discipline: PASS by non-use. No new destructive or risky actions are introduced. +- Global search: PASS. No new resource or search result is added. +- OperationRun / Ops-UX: PASS by non-use. No new run start or completion behavior exists. +- Data minimization: PASS. Default-visible content stays limited to family summaries, lane scope, and next action. +- Test governance: PASS. Proof remains in focused `Unit` and `Feature` lanes. +- Proportionality / no premature abstraction: PASS. The design extends an existing page and builder instead of introducing a new shell or engine. +- Persisted truth: PASS. No new table, artifact, or cached projection is introduced. +- Behavioral state: PASS. Any additional family key remains derived page state only. +- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing page and navigation patterns are extended rather than bypassed. +- Provider boundary: PASS. No provider/platform seam widens. +- Filament/Laravel panel safety: PASS. Filament v5 stays on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, and no new assets are planned. + +**Gate evaluation**: PASS. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Unit` for section assembly and convergence routing, `Feature` for page visibility, family omission, and navigation continuity +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: unit coverage proves derived-family assembly cheaply; feature coverage proves route access, family omission, tenant-prefilter continuity, and duplicate-truth prevention on existing pages +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` +- **Fixture / helper / factory / seed / context cost risks**: moderate; reuse existing workspace, tenant, finding, exception, and review fixtures without widening into browser or heavy-governance families +- **Expensive defaults or shared helper growth introduced?**: no +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: `global-context-shell` coverage is required because return context and tenant-filter continuity are part of the contract +- **Closing validation and reviewer handoff**: rerun the focused commands above, verify the governance home stays read-only, and confirm specialist surfaces preserve lane-specific truth without duplicating the workspace summary +- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local increase +- **Review-stop questions**: lane fit, hidden fixture growth, accidental new shell, accidental new mutation lane, hidden leakage through counts +- **Escalation path**: `document-in-feature` for contained navigation-context notes; `reject-or-split` for any new shell or workflow-engine drift +- **Active feature PR close-out entry**: Guardrail / Smoke Coverage +- **Test-governance outcome**: keep +- **Why no dedicated follow-up spec is needed**: the bounded convergence work remains feature-local unless future work demands a broader dashboard or portfolio action-center spec + +## Rollout & Risk Controls + +- Keep the governance inbox as the only primary start surface touched by this slice. +- Keep all specialist mutations on their existing pages. +- Do not widen the exception or review lane into new workflow state. +- Prefer extending the current section builder and navigation helper over adding a new orchestrator. +- Treat any attempt to add a second workspace summary banner on the specialist pages as out-of-scope drift. + +## Project Structure + +### Documentation (this feature) + +```text +specs/257-governance-decision-convergence/ +├── checklists/ +│ └── requirements.md +├── spec.md +├── plan.md +└── tasks.md +``` + +This preparation package intentionally stays on the core artifacts plus the review checklist. The repo truth is already known, the slice adds no new persistence or external contract, and no extra research/data-model/contracts package is required to make the implementation bounded. + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/Pages/ +│ │ ├── Findings/ +│ │ │ ├── MyFindingsInbox.php +│ │ │ └── FindingsIntakeQueue.php +│ │ ├── Governance/ +│ │ │ └── GovernanceInbox.php +│ │ ├── Monitoring/ +│ │ │ └── FindingExceptionsQueue.php +│ │ └── Reviews/ +│ │ └── CustomerReviewWorkspace.php +│ └── Support/ +│ ├── GovernanceInbox/ +│ │ └── GovernanceInboxSectionBuilder.php +│ ├── Navigation/ +│ │ └── CanonicalNavigationContext.php +│ └── OperateHub/ +│ └── OperateHubShell.php +└── resources/views/filament/pages/ + ├── findings/ + │ ├── my-findings-inbox.blade.php + │ └── findings-intake-queue.blade.php + ├── governance/ + │ └── governance-inbox.blade.php + ├── monitoring/ + │ └── finding-exceptions-queue.blade.php + └── reviews/ + └── customer-review-workspace.blade.php +``` + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| One additional derived family in `GovernanceInboxSectionBuilder` | the exception lane still sits outside the canonical decision home | leaving exceptions on a standalone specialist page keeps the current fragmented start state | +| One bounded convergence contract for arrival and return context | specialist pages need a truthful way back to the same governance scope | page-local ad hoc back links would drift across surfaces and duplicate navigation logic | + +## Proportionality Review + +- **Current operator problem**: operators still have to decide between several repo-real specialist surfaces before they can begin work. +- **Existing structure is insufficient because**: the current governance home does not yet own all high-signal lanes and the specialist pages do not clearly behave as secondary contexts. +- **Narrowest correct implementation**: extend the existing governance inbox and navigation continuity instead of adding a new shell or persisted workflow engine. +- **Ownership cost created**: maintain one more derived family, one bounded convergence helper, and focused tests. +- **Alternative intentionally rejected**: a new action-center page or persisted cross-family work queue was rejected as unnecessary structure for current-release truth. +- **Release truth**: current-release workflow compression. + +## Implementation Strategy + +### Suggested MVP Scope + +MVP = **User Story 1 + User Story 2 together**. The convergence slice only becomes meaningful once the governance home shows the missing lanes and the specialist surfaces preserve truthful return context. + +### Incremental Delivery + +1. Extend the governance inbox family assembly and page rendering. +2. Add convergence-aware arrival and return semantics on the specialist pages. +3. Tighten duplicate-truth prevention and calm secondary-context copy. +4. Finish with focused validation and formatting. + +### Team Strategy + +1. Settle the governance inbox family extension and navigation-context contract first. +2. Parallelize unit coverage for builder behavior and feature coverage for navigation continuity. +3. Serialize merges around the shared governance inbox and specialist page views so the decision-home language stays coherent. \ No newline at end of file diff --git a/specs/257-governance-decision-convergence/spec.md b/specs/257-governance-decision-convergence/spec.md new file mode 100644 index 00000000..34c639df --- /dev/null +++ b/specs/257-governance-decision-convergence/spec.md @@ -0,0 +1,320 @@ +# Feature Specification: Governance Decision Surface Convergence v1 + +**Feature Branch**: `257-governance-decision-convergence` +**Created**: 2026-04-29 +**Status**: Ready for implementation +**Input**: User description: "Prepare the roadmap-fit Decision-Based Operating Foundations slice as Governance Decision Surface Convergence: use the existing governance inbox as the canonical workspace decision home, converge the still-missing exception and customer-review decision lanes, and keep the work read-only, repo-grounded, and free of new workflow state." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot already has multiple repo-real decision surfaces such as `MyFindingsInbox`, `FindingsIntakeQueue`, `GovernanceInbox`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace`, but operators still have to decide where to start by hopping between specialist pages. +- **Today's failure**: The product has a decision-first foundation, but it still lacks one clearly dominant workspace decision home that converges the open exception lane and the customer-review follow-up lane into the same calm operating model. +- **User-visible improvement**: An authorized workspace operator lands on one canonical governance home, sees the remaining high-signal lanes in one place, and can open a specialized surface with preserved context and a truthful return path instead of reconstructing the workflow manually. +- **Smallest enterprise-capable version**: Reuse the existing `/admin/governance/inbox` page as the canonical decision home, extend it with a derived `finding_exceptions` family from existing queue truth, make customer-review follow-up handoff explicit through existing review workspace surfaces, and align `My Findings`, `Findings intake`, `Finding exceptions`, and `Customer Review Workspace` behind shared arrival and return context. No new page shell, no new persistence, and no new mutation lane ship in this slice. +- **Explicit non-goals**: No new portal or dashboard shell, no new persisted inbox item or task state, no new assign/claim/approve/review mutations on the convergence surface, no cross-tenant compare or promotion work, no customer-facing portfolio board, no AI prioritization, and no generic workflow framework. +- **Permanent complexity imported**: One bounded family extension inside the existing governance inbox assembly path, one small convergence contract for arrival and return context across existing pages, and focused unit plus feature coverage. +- **Why now**: `docs/product/roadmap.md` still has an open `Decision-Based Operating Foundations` lane, while `docs/product/implementation-ledger.md` identifies decision-surface fragmentation as the highest unspecced operator workflow gap after already-specced compare, commercial, and cleanup packages are removed from the queue. +- **Why not local**: Extending only `FindingExceptionsQueue` or only `CustomerReviewWorkspace` would keep the current start-state ambiguity intact and would not establish a single truthful operator entry point. +- **Approval class**: Workflow Compression +- **Red flags triggered**: One multi-surface convergence flag and one derived-family-extension flag. Defense: the slice extends existing pages and builders, introduces no new persistence, and keeps all workflow state on the existing source surfaces. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view +- **Primary Routes**: + - existing canonical workspace route `/admin/governance/inbox` + - existing `/admin/findings/my-work` + - existing `/admin/findings/intake` + - existing `/admin/finding-exceptions/queue` + - existing `/admin/reviews/workspace` + - existing tenant-scoped finding and review detail routes as drill-through targets only +- **Data Ownership**: + - `Finding`, `FindingException`, `TenantTriageReview`, `TenantReview`, `ReviewPack`, `AlertDelivery`, and `OperationRun` remain the only persisted truth for their respective families + - the convergence layer remains derived from the existing `GovernanceInbox` page and supporting builders; it introduces no new inbox-item table, cache, mirror entity, or workflow state + - no new review, exception, or decision summary persistence is introduced +- **RBAC**: + - workspace membership remains the first boundary for the canonical decision home and all converged launches + - non-members and explicit out-of-scope tenant filters remain `404` deny-as-not-found boundaries + - in-scope members who can access none of the converged families receive `403`, not a silent empty shell + - findings lanes continue to reuse `Capabilities::TENANT_FINDINGS_VIEW` and keep claim or assignment semantics on their existing pages, including `Capabilities::TENANT_FINDINGS_ASSIGN` + - the exception lane reuses the existing `FindingExceptionsQueue` visibility contract based on `Capabilities::FINDING_EXCEPTION_APPROVE`; this slice must not introduce a second exception capability family + - customer-review follow-up and review-pack handoff continue to reuse existing review and pack visibility checks; this slice must not introduce a new review-workspace capability family + - the convergence surface stays read-only; all mutations remain enforced on their existing source pages and actions + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: When launched from a tenant-scoped or tenant-prefiltered specialist page, the governance inbox prefilters to that tenant and, when relevant, to the originating family. Clearing filters returns to the workspace-wide decision home instead of preserving a local specialist scope forever. +- **Explicit entitlement checks preventing cross-tenant leakage**: Broad workspace listings silently omit inaccessible tenants and hidden families from counts, labels, and previews. Explicit tenant or record targets outside visible scope resolve as not found and do not leak family counts or presence hints. + +## 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 +- **Interaction class(es)**: navigation entry points, decision-home section summaries, action links, queue empty states, back-link continuity, and badge or status reuse +- **Systems touched**: `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, `CustomerReviewWorkspace`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and existing action-surface declarations on those pages +- **Existing pattern(s) to extend**: the existing governance inbox route and section builder, tenant-prefilter state handling, canonical navigation context, calm empty-state copy on specialist pages, and existing read-first decision surfaces +- **Shared contract / presenter / builder / renderer to reuse**: `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, `ActionSurfaceDeclaration`, and existing source-page capability guards +- **Why the existing shared path is sufficient or insufficient**: the existing source pages already own the truth and the current governance inbox already owns the canonical route, but the current convergence is insufficient because finding exceptions and explicit customer-review decision continuity still sit outside the calm default operating model +- **Allowed deviation and why**: none. The slice should extend the existing governance inbox and source pages instead of introducing a second convergence shell or a generic task framework. +- **Consistency impact**: `Governance inbox`, `Open my findings`, `Open findings intake`, `Open finding exceptions`, `Open customer review workspace`, and `Back to governance inbox` language must stay consistent across section copy, specialist page affordances, and empty-state recovery actions. +- **Review focus**: reviewers must block any implementation that creates a new standalone convergence page, adds local specialist mutations to the governance home, or duplicates specialist proof content on the decision surface. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: no new `OperationRun` start or completion behavior is introduced +- **Shared OperationRun UX contract/layer reused**: existing deep-link-only behavior on the already-present stale-operations family remains unchanged +- **Delegated start/completion UX behaviors**: `N/A` +- **Local surface-owned behavior that remains**: the governance home still decides whether the existing stale-operations section is shown, but this spec does not widen or redefine that contract +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +N/A - no new provider or platform-core boundary is widened. This slice only converges existing operator-facing decision 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 | +|---|---|---|---|---|---|---| +| Governance inbox page | yes | Native Filament page plus existing section builder | decision-home summaries, navigation entry points, badge reuse | page, URL-query, derived family state | no | Existing canonical route remains authoritative; no new shell is added | +| Findings and exception specialist queues (`MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`) | yes | Native Filament pages | arrival context, return continuity, calm secondary-context copy | page, URL-query, table/session filter restore | no | Remain specialized secondary-context surfaces; no workflow ownership moves here | +| Customer review workspace | yes | Native Filament page | review-follow-up handoff, return continuity, calm read-only context | page, URL-query, table/session filter restore | no | Remains the review-consumption surface, not a second governance home | + +## 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 | +|---|---|---|---|---|---|---|---| +| Governance inbox page | Primary Decision Surface | Operator decides which lane to open next across findings, exceptions, stale operations, alert failures, and review follow-up | visible family counts, top blockers, tenant scope, and one dominant next action per section | specialist queue details, review detail, alert detail, and finding detail after explicit open | Primary because it becomes the single workspace start surface instead of one of several competing starts | Aligns the product with the roadmap's decision-first operating direction | Reduces page hopping before the first action | +| Findings and exception specialist queues | Secondary Context | Operator acts inside the chosen lane after the first decision is already made | lane-specific list rows, current tenant scope, and one existing safe next action | record detail, due context, approval context, and deeper diagnostics on existing detail surfaces | Secondary because the lane is chosen at the governance home, not discovered here first | Keeps specialist work inside the existing pages without making them compete as starts | Removes the need to re-evaluate the whole workspace after every lane jump | +| Customer review workspace | Secondary Context | Operator verifies the latest customer-safe review state after a governance or review-follow-up cue | latest published review outcome, pack availability, and read-only summary | full review detail and pack download after explicit open | Secondary because it remains a read-only review-consumption surface entered from a governance cue | Preserves the customer-safe review workflow while fitting into the same decision hierarchy | Prevents the review workspace from becoming a competing attention home | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Governance inbox page | operator-MSP | family summary, tenant scope, top blockers, and section CTA | diagnostics stay on the specialist pages | raw payloads and debug detail stay on existing source pages only | `Open attention lane` | raw detail is never rendered on the decision home | the page states the current blocker once and sends the operator to the owning surface for proof | +| Findings and exception specialist queues | operator-MSP | lane-specific queue rows, due cues, status, and existing next action | specialist diagnostics and record detail remain available on existing routes | raw/support detail stays behind existing record detail affordances | existing lane-owned action only | any broad workspace summary stays off the specialist pages | specialist pages do not restate the workspace decision-home summary | +| Customer review workspace | operator-MSP, customer-read-only | latest customer-safe review status, pack availability, and read-only summary | deeper review diagnostics stay on review detail | raw JSON, provider payloads, and internal support evidence remain hidden or gated on existing detail/download paths | `Open latest review` | support-only raw evidence stays off the workspace surface | review workspace states review truth once and relies on review detail for proof | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Governance inbox page | Utility / Workspace Decision | Read-only workflow hub | Open the correct existing lane | explicit section CTA or preview-entry CTA | forbidden | section footer links and preview-entry links only | none | `/admin/governance/inbox` | existing specialist routes only | active workspace, optional tenant and family filter | Governance inbox | which lane needs attention now | none | +| Findings and exception specialist queues | List / Table / Read-only decision queue | Specialist queue | Open the owning record or use the existing inline safe shortcut | row click and existing specialist inspect path | required | existing lane-owned actions only | existing grouped destructive or risky actions remain on owning detail surfaces | `/admin/findings/my-work`, `/admin/findings/intake`, `/admin/finding-exceptions/queue` | existing finding or exception detail routes | tenant filter, queue view, queue-specific states | Findings / Finding exceptions | lane-specific actionable truth only | none | +| Customer review workspace | List / Table / Read-only workspace report | Read-only review consumption | Open the latest published review | clickable row to latest review or explicit download/open action | required | existing read-only actions only | none | `/admin/reviews/workspace` | existing tenant review detail route | tenant filter and review availability | Customer review workspace | latest published review state and pack availability | 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Governance inbox page | Workspace operator / MSP operator | Decide which governance lane to open next | Workspace decision hub | What needs attention now across my visible governance lanes? | family counts, top blockers, tenant scope, and section CTA | diagnostics remain on specialist pages | lane urgency, lane ownership, tenant scope | none | Open lane | none | +| Findings and exception specialist queues | Workspace operator / MSP operator | Work inside a chosen findings or exception lane | Specialist queue | What in this chosen lane needs action first? | queue rows, due or review cues, owner/assignee context, and current filter state | full record detail and deep diagnostics remain on existing detail routes | workflow status, due/overdue state, review or approval state | existing lane-owned mutations only | inspect record or existing lane action | existing queue-owned risky actions only | +| Customer review workspace | Workspace operator / readonly-capable tenant actor | Consume the latest review truth after a review-follow-up cue | Read-only review workspace | What is the latest published review state for this tenant? | latest review outcome, pack availability, and read-only summary | full review detail and pack detail after explicit open | review availability, review freshness, pack availability | none | Open latest review | 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 convergence contract inside the existing governance inbox assembly and navigation-context seams +- **New enum/state/reason family?**: no new persisted family; any added family key remains derived and page-scoped +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: operators still start from multiple repo-real specialist surfaces even though the repo already has enough decision surfaces to support one calmer governance home +- **Existing structure is insufficient because**: the current governance inbox does not yet own all remaining high-signal lanes and the specialist pages do not clearly behave as secondary contexts +- **Narrowest correct implementation**: extend the existing governance inbox and current specialist pages with one more derived family and shared arrival/return semantics instead of creating a new shell or workflow engine +- **Ownership cost**: maintain one more derived section and a small navigation-context convergence layer plus focused tests +- **Alternative intentionally rejected**: a new global action center, persisted inbox-item table, and mutation-capable workflow engine were rejected as premature and structurally heavier than the current release truth requires +- **Release truth**: current-release workflow compression, not future-release platform speculation + +### 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 extension of the existing governance inbox is preferred over adding a parallel decision surface. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: unit coverage proves section assembly, family ordering, and convergence routing without Filament boot cost; focused feature coverage proves visibility, tenant-filter continuity, return context, and calm secondary-surface behavior on the existing pages +- **New or expanded test families**: focused `Unit/Support/GovernanceInbox` coverage plus focused `Feature/Governance`, `Feature/Monitoring`, `Feature/Reviews`, and `Feature/Findings` convergence coverage +- **Fixture / helper cost impact**: moderate; tests need workspace membership, visible and hidden tenants, findings, exceptions, review-follow-up states, and review-workspace fixtures, but should reuse existing factories and avoid browser or heavy-governance setup +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: global-context-shell +- **Standard-native relief or required special coverage**: special coverage is required for arrival/return context and duplicate-truth prevention across the specialist pages +- **Reviewer handoff**: reviewers must confirm that the governance inbox remains the single start surface, counts omit inaccessible families, specialist pages keep one dominant action, and no new mutation lane or persistence appears +- **Budget / baseline / trend impact**: low feature-local increase only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail / Smoke Coverage +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` + +## Scope Boundaries + +### In Scope + +- reuse the existing `GovernanceInbox` page as the canonical workspace decision home +- extend the governance inbox with a derived finding-exceptions family sourced from existing queue truth +- make customer-review follow-up and customer-review-workspace handoff explicit within the same decision hierarchy +- preserve tenant and family arrival context plus truthful return links between the governance home and `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace` +- keep specialist pages as calm secondary-context surfaces with no duplicate workspace-level blocker summary + +### Non-Goals + +- creating a new global action-center page or dashboard shell +- replacing the existing specialist pages or moving their mutations to the governance home +- adding a new persisted inbox item, queue state, or workflow engine +- changing existing finding, exception, or review lifecycle semantics +- cross-tenant compare, promotion, or portfolio execution work +- customer-facing portfolio boards or AI-driven prioritization + +## Assumptions + +- the existing `GovernanceInboxSectionBuilder` can accept one more derived family without turning into a generic task engine +- current `CanonicalNavigationContext` and tenant-prefilter handling are sufficient to preserve truthful return paths between the decision home and specialist pages +- `CustomerReviewWorkspace` remains the correct read-only destination for customer-safe review consumption when a published review detail is not the better direct target + +## Risks + +- implementation could overreach and turn the governance home into a new task engine instead of a routing surface +- the finding-exceptions family could leak hidden tenant hints if capability and tenant scoping are not applied before counts and previews are derived +- specialist-page convergence could accidentally duplicate blocker language instead of keeping the decision summary on the governance home only + +## Follow-up Candidates + +- wider dashboard-entry convergence once the governance home proves adoption +- portfolio-level decision convergence with cross-tenant compare after Spec 043 implementation +- any mutation consolidation only after the read-only convergence hierarchy is proven and remains bounded + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Use one canonical governance home (Priority: P1) + +As a workspace operator, I want one governance home that includes the still-missing exception and review-consumption lanes so I can decide where to work next without choosing between multiple start pages first. + +**Why this priority**: This is the smallest slice that completes the roadmap's decision-first operating direction without inventing new workflow state. + +**Independent Test**: Seed visible findings, finding exceptions, and review-follow-up states, open the governance inbox, and verify that the page shows the converged lanes with calm summaries and section CTAs. + +**Acceptance Scenarios**: + +1. **Given** the actor can see findings, finding exceptions, and review follow-up for the current workspace, **When** they open the governance inbox, **Then** the page shows those lanes in one canonical surface with one dominant action per section. +2. **Given** the actor cannot see finding exceptions, **When** they open the governance inbox, **Then** the exception lane does not appear and no count or empty-state hint implies hidden work exists. +3. **Given** the actor applies a tenant-prefilter that hides all current rows, **When** they open the governance inbox, **Then** the page explains that the tenant filter is hiding other visible attention instead of falsely implying the whole workspace is calm. + +--- + +### User Story 2 - Move into a specialist lane and back without losing context (Priority: P1) + +As a workspace operator, I want to open a specialist queue or review workspace from the governance home and come back with the same tenant and family context so the governance home becomes my operating anchor instead of a one-off report. + +**Why this priority**: Convergence does not help if every lane jump loses the original decision context. + +**Independent Test**: Open the governance inbox with tenant and family filters, jump into a specialist page, and verify that the specialist page exposes a truthful return path back to the same governance scope. + +**Acceptance Scenarios**: + +1. **Given** the actor opens `My Findings` or `Findings intake` from the governance inbox, **When** the specialist queue loads, **Then** the existing queue keeps its specialist semantics while exposing a truthful return path to the governance inbox. +2. **Given** the actor opens `Finding exceptions` from the governance inbox, **When** the queue loads, **Then** the queue preserves the arrival tenant context and does not become a competing workspace start surface. +3. **Given** the actor opens `Customer Review Workspace` from a review-follow-up cue, **When** they inspect the review lane, **Then** the page stays read-only and preserves the governance-home return path. + +--- + +### User Story 3 - Keep specialist surfaces calm and secondary (Priority: P2) + +As a workspace operator, I want specialist queues and the customer review workspace to keep their own lane truth without re-explaining the whole workspace blocker summary so each page stays focused on the action I already chose. + +**Why this priority**: Convergence should reduce attention load, not spread the same summary across more pages. + +**Independent Test**: Open the governance home and then each specialist surface, and verify that the specialist page keeps its lane-specific content while the workspace-level blocker summary remains on the governance home. + +**Acceptance Scenarios**: + +1. **Given** the actor opens a specialist queue from the governance home, **When** the specialist page renders, **Then** it shows only lane-specific actionable truth and not a duplicated workspace summary banner. +2. **Given** the actor opens the customer review workspace from a review-follow-up cue, **When** the page renders, **Then** it shows customer-safe review truth and not a second governance-home summary card. + +### Edge Cases + +- the actor can access the governance inbox but none of the converged specialist families +- the requested tenant filter is outside the actor's visible scope +- the same tenant has findings, exceptions, and review follow-up at once, but the governance home must still avoid duplicating the same blocker explanation across sections +- review follow-up exists for a tenant without a currently published review, requiring the fallback customer-review-workspace destination +- the selected tenant is calm for exceptions but not for other families, so the empty-state message must be truthful about what the filter is hiding + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-257-001 Canonical decision home**: The system MUST treat the existing `/admin/governance/inbox` page as the canonical workspace decision home for operator-facing governance attention. +- **FR-257-002 Exception-family convergence**: The governance inbox MUST derive a `finding_exceptions` family from existing `FindingExceptionsQueue` truth and render it without adding a new persisted inbox item or queue-state layer. +- **FR-257-003 Review-consumption handoff**: Review-follow-up cues on the governance inbox MUST route into the existing review-consumption surfaces, using the latest published review when available and the existing customer review workspace when it is the truthful fallback. +- **FR-257-004 Arrival and return continuity**: Launching `My Findings`, `Findings intake`, `Finding exceptions`, or `Customer Review Workspace` from the governance inbox MUST preserve truthful arrival and return context for the current tenant and family scope. +- **FR-257-005 Secondary-surface discipline**: Specialist queues and the customer review workspace MUST remain secondary-context surfaces and MUST NOT become competing workspace start surfaces through duplicated workspace summary banners or second primary CTAs. +- **FR-257-006 Visibility and omission semantics**: Family counts, section previews, and empty states MUST be derived only from tenants and families the actor can already see through existing capability and entitlement checks. +- **FR-257-007 Authorization semantics**: Non-members and out-of-scope tenant targets MUST resolve as `404`, while in-scope members who lack visibility to every converged family MUST receive `403`. +- **FR-257-008 No new workflow truth**: The slice MUST NOT add a new inbox-item table, a persisted convergence state, or a new cross-family mutation contract. +- **FR-257-009 Source-surface ownership**: Claim, assignment, approval, review, and pack-download behaviors MUST remain on their existing source surfaces and continue to enforce their existing capabilities there. +- **FR-257-010 Decision-first disclosure**: The governance inbox MUST show summary and next-action content only; raw payloads, review evidence, and specialist diagnostics MUST remain on the owning specialist or detail surfaces. +- **FR-257-011 Duplicate-truth prevention**: The governance inbox and the specialist surfaces MUST NOT restate the same workspace-level blocker or next-action summary as equal-priority content. +- **FR-257-012 Read-only review integrity**: The customer review workspace remains read-only in this slice and MUST NOT gain operator-only mutation controls through convergence work. + +### Non-Functional Requirements + +- **NFR-257-001**: The convergence layer remains DB-only and derived from existing persisted truth; it MUST NOT add Graph calls, remote calls, queues, or `OperationRun` creation. +- **NFR-257-002**: The slice MUST reuse existing Filament and shared UI primitives before any local UI framework or semantic layer is introduced. +- **NFR-257-003**: The feature MUST stay within focused `Unit` and `Feature` lanes only; browser or heavy-governance coverage is out of scope unless implementation proves a specific need. + +### UX Requirements + +- **UXR-257-001**: The governance inbox remains the one dominant start surface for the converged lanes. +- **UXR-257-002**: Each affected surface has exactly one dominant next action visible by default. +- **UXR-257-003**: Specialist surfaces keep lane-specific truth only and rely on explicit return links for workspace context. + +### RBAC / Security Requirements + +- **RBR-257-001**: The slice MUST reuse existing capability registries and MUST NOT introduce raw capability strings or role-name checks. +- **RBR-257-002**: Tenant-filter and family-filter state MUST NOT leak inaccessible tenant or family hints through counts, labels, or empty-state copy. + +### Auditability / Observability Requirements + +- **AOR-257-001**: The slice MUST NOT create a new page-view audit stream; existing audit ownership remains on the existing source-surface mutations and downloads. +- **AOR-257-002**: Any convergence-specific navigation or UI state remains derived and inspectable through tests rather than new runtime logging. + +### Data / Truth-Source Requirements + +- **DTR-257-001**: `Finding`, `FindingException`, `TenantTriageReview`, `TenantReview`, `ReviewPack`, `AlertDelivery`, and `OperationRun` remain the only source truth inputs for the decision home. +- **DTR-257-002**: Any added convergence family key remains derived page state, not persisted domain truth. + +## Out of Scope + +- new persistence or workflow-state layers +- new operator mutations on the governance home +- cross-tenant compare or promotion work +- customer-facing portfolio boards or customer portal changes +- AI prioritization or recommendation logic + +## Acceptance Criteria + +- the selected operator can open one canonical governance home that includes the missing exception lane and truthful review-consumption handoff without seeing a second competing start surface +- specialist pages preserve truthful arrival and return context when opened from the governance home +- hidden families and inaccessible tenants do not leak through counts, labels, or empty-state hints +- the customer review workspace remains read-only and customer-safe while participating in the same decision hierarchy +- no new persistence, workflow state, queue, or runtime mutation surface is introduced + +## Success Criteria + +- operators can explain one default start surface for governance work in the workspace +- the specialist pages feel like chosen lanes, not competing homes +- implementation can stay bounded to existing page and builder seams with no new framework layer + +## Open Questions + +- none diff --git a/specs/257-governance-decision-convergence/tasks.md b/specs/257-governance-decision-convergence/tasks.md new file mode 100644 index 00000000..f13816b3 --- /dev/null +++ b/specs/257-governance-decision-convergence/tasks.md @@ -0,0 +1,189 @@ +--- + +description: "Task list for Governance Decision Surface Convergence v1" + +--- + +# Tasks: Governance Decision Surface Convergence v1 + +**Input**: Design documents from `specs/257-governance-decision-convergence/` +**Prerequisites**: `specs/257-governance-decision-convergence/plan.md` (required), `specs/257-governance-decision-convergence/spec.md` (required) + +**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage for this read-only convergence slice. +**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Existing stale-operation links remain unchanged. +**RBAC**: Workspace membership remains the first boundary. Non-members or out-of-scope tenant targets return `404`; in-scope members with no visible family return `403`. Findings lanes reuse `Capabilities::TENANT_FINDINGS_VIEW`, existing inline safe actions keep their current capability checks such as `Capabilities::TENANT_FINDINGS_ASSIGN`, the exception lane reuses `Capabilities::FINDING_EXCEPTION_APPROVE`, and review handoff reuses existing review and pack visibility checks. +**Shared Pattern Reuse**: Reuse `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and the existing specialist page action-surface contracts. No new shell, task engine, or persistence layer is allowed. +**Organization**: Tasks are grouped by user story so the governance-home extension, navigation convergence, and calm secondary-context rules remain independently testable after the shared groundwork is complete. + +## Test Governance Checklist + +- [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior. +- [x] New or changed tests stay in focused `apps/platform/tests/Unit/Support/GovernanceInbox/`, `apps/platform/tests/Feature/Governance/`, `apps/platform/tests/Feature/Findings/`, `apps/platform/tests/Feature/Monitoring/`, and `apps/platform/tests/Feature/Reviews/` families only. +- [x] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or generic workflow fixtures. +- [x] Planned validation commands cover governance-home assembly, authorization, and arrival/return continuity without widening scope. +- [x] The declared surface test profile remains `global-context-shell` because arrival context and tenant-filter continuity are part of the contract. +- [x] Any broader action-center, dashboard-entry, or cross-tenant follow-up resolves as `document-in-feature` or `follow-up-spec`, not hidden implementation growth. +- [x] Test-governance outcome resolves as `keep` for this feature and does not widen the work into a heavier family. + +## Phase 1: Setup (Shared Context) + +**Purpose**: Confirm the bounded convergence slice, the existing governance-home seams, and the reviewer stop conditions before implementation begins. + +- [x] T001 Review the bounded convergence slice in `specs/257-governance-decision-convergence/spec.md` and `specs/257-governance-decision-convergence/plan.md` together with `docs/product/roadmap.md` and `docs/product/implementation-ledger.md`. +- [x] T002 [P] Confirm the current governance-home families and summary seams in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`. +- [x] T003 [P] Confirm the specialist-page arrival, return, and filter-state seams in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php`, `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, and `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Extend the shared governance-home and navigation seams that every user story depends on. + +**Critical**: No user-story work should begin until this phase is complete. + +- [x] T004 [P] Define or extend the bounded family-aware arrival and return contract inside `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` and any minimal supporting helper under `apps/platform/app/Support/GovernanceInbox/` without creating new persistence or a generic workflow framework. +- [x] T005 [P] Tighten family omission and access evaluation in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` so inaccessible tenants and families disappear before counts are derived and in-scope no-family access resolves as `403`. +- [x] T006 Implement the derived `finding_exceptions` family in `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` using existing `FindingExceptionsQueue` truth, current queue semantics, and existing capability rules. +- [x] T007 Implement truthful review-consumption handoff logic in `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` and `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` so review-follow-up entries prefer existing latest review detail and fall back to `CustomerReviewWorkspace` only when that is the honest destination. +- [x] T008 [P] Update `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to keep one dominant CTA per section and avoid duplicate workspace-summary cards as the new family is added. + +**Checkpoint**: The governance home can derive the new family, family counts stay capability-safe, and navigation context rules are settled before story-specific work begins. + +--- + +## Phase 3: User Story 1 - Use One Canonical Governance Home (Priority: P1) + +**Goal**: Give the operator one governance home that includes the missing exception and review-consumption lanes without creating a new shell. + +**Independent Test**: Seed visible findings, exceptions, and review-follow-up states, open the governance inbox, and verify that the page shows the converged lanes with calm summaries and one dominant CTA per section. + +### Tests for User Story 1 + +- [x] T009 [P] [US1] Extend `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` to cover exception-family inclusion, family ordering, review-workspace fallback, and omission semantics for hidden tenants or families. +- [x] T010 [P] [US1] Extend `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php` to cover the visible exception lane, review-consumption handoff summary, tenant-filter empty-state truth, and one dominant CTA per section. +- [x] T011 [P] [US1] Extend `apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` to cover `404` vs `403` behavior when workspace access exists but all converged family visibility is removed. + +### Implementation for User Story 1 + +- [x] T012 [US1] Update `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to render the new convergence lane and family-aware summary or empty-state copy. +- [x] T013 [US1] Align governance-home copy in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to the stable vocabulary `Governance inbox`, `Open my findings`, `Open findings intake`, `Open finding exceptions`, and `Open customer review workspace`. + +**Checkpoint**: User Story 1 is independently functional when the governance inbox truthfully shows the missing lane and routes to the existing specialist destinations. + +--- + +## Phase 4: User Story 2 - Move Into A Specialist Lane And Back (Priority: P1) + +**Goal**: Preserve tenant and family context when the operator opens a specialist page from the governance home and returns. + +**Independent Test**: Open the governance inbox with tenant and family filters, jump into a specialist page, and verify that the specialist page exposes a truthful return path back to the same governance scope. + +### Tests for User Story 2 + +- [x] T014 [P] [US2] Add or extend `apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php` for tenant and family arrival/return continuity across governance-home launches. +- [x] T015 [P] [US2] Add or extend `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php` for governance-home arrival, preserved tenant context, and truthful `Back to governance inbox` continuity. +- [x] T016 [P] [US2] Add or extend `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` for governance-home arrival and return continuity on review-follow-up launches, preferred latest-published-review destination when available, fallback to `CustomerReviewWorkspace` when not, preserved read-only state, and the absence of operator-only mutation controls. +- [x] T017 [P] [US2] Add or extend `apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php` and `apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php` for governance-home launch and return continuity on the findings specialist pages. + +### Implementation for User Story 2 + +- [x] T018 [US2] Wire governance-home arrival and return context through `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`. +- [x] T019 [US2] Expose truthful return affordances without adding a second primary CTA. Repo truth: these native Filament pages expose the affordance through page header actions in `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace`; no specialist Blade edits were required. + +**Checkpoint**: User Story 2 is independently functional when the operator can move between the governance home and the specialist pages without losing truthful context. + +--- + +## Phase 5: User Story 3 - Keep Specialist Surfaces Calm And Secondary (Priority: P2) + +**Goal**: Ensure the specialist pages stay focused on lane-specific truth and do not duplicate the workspace-level summary once convergence context exists. + +**Independent Test**: Open the governance home and then each specialist surface, and verify that the specialist page keeps lane-specific content while the workspace-level blocker summary remains on the governance home only. + +### Tests for User Story 3 + +- [x] T020 [P] [US3] Add or extend `apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php`, `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php`, and `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` to assert duplicate-truth prevention, one dominant default action on each specialist surface, and secondary-context copy when the pages are opened from the governance home. + +### Implementation for User Story 3 + +- [x] T021 [US3] Keep lane-specific summaries focused and avoid duplicating workspace-level blocker text. Repo truth: page classes now add secondary return context while existing specialist Blade views stay lane-focused; regression tests assert the governance-home summary text is absent from secondary pages. +- [x] T022 [US3] Align action-surface declarations, header affordances, and empty-state recovery actions across `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` so the canonical start surface remains obvious. + +**Checkpoint**: User Story 3 is independently functional when specialist surfaces remain lane-specific secondary contexts instead of competing starts. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finish narrow validation and reviewer close-out without widening scope. + +- [x] T023 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`. +- [x] T024 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php tests/Feature/Governance/GovernanceInboxAuthorizationTest.php tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php`. +- [x] T025 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched platform files. +- [x] T026 [P] Confirm the slice introduced no new asset registration, no new globally searchable resource, and no new mutation lane; record any bounded follow-up for broader dashboard-entry or portfolio action-center work in the active implementation notes. +- [x] T027 [P] Confirm the slice introduced no new Graph or remote calls, no queue or `OperationRun` start path, and no page-view audit or runtime logging stream; record any bounded follow-up if implementation uncovers a structural need outside this slice. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: no dependencies; start immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories. +- **Phase 3 (US1)**: depends on Phase 2 and establishes the canonical governance-home truth. +- **Phase 4 (US2)**: depends on Phase 2 and should ship with US1 so the governance home is not a dead-end report. +- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 and US2 because specialist pages must already participate in the convergence flow. +- **Phase 6 (Polish)**: depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: independently testable after Phase 2 and establishes the new canonical decision-home behavior. +- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 so the new home has truthful workflow continuity. +- **US3 (P2)**: independently testable after Phase 2 and refines the specialist pages once the convergence contract exists. + +### Within Each User Story + +- After the shared foundational contract work in Phase 2 is complete, write the listed Pest coverage first for each user story and make it fail for the intended gap. +- Land the shared builder and navigation contract before widening Blade or copy work. +- Re-run the narrowest affected validation command after each story checkpoint before moving to the next story. + +--- + +## Parallel Execution Examples + +### User Story 1 + +- T009, T010, and T011 can run in parallel before runtime edits begin. +- After the family contract settles, T012 and T013 can proceed in parallel because rendering and copy alignment touch different seams. + +### User Story 2 + +- T014, T015, T016, and T017 can run in parallel because they cover different destinations in the convergence flow. +- After T018 settles the shared navigation contract, T019 can follow to align the Blade affordances. + +### User Story 3 + +- T020 can start before implementation finishes because it only captures the expected secondary-context behavior. +- T021 and T022 can proceed together once the shared convergence path is stable. + +--- + +## Implementation Strategy + +### Suggested MVP Scope + +- MVP = **US1 + US2 together**. The slice becomes product-meaningful only when the governance home shows the missing lanes and the specialist pages preserve truthful return context. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 and US2 together. +3. Add US3 secondary-context tightening. +4. Finish with focused validation and formatting in Phase 6. + +### Team Strategy + +1. Settle the governance-home family extension and navigation-context contract first. +2. Parallelize unit and feature coverage inside each story before runtime edits widen. +3. Serialize merges around the governance inbox and specialist Blade views so the decision-home language stays coherent. -- 2.45.2 From 61feb48d8a6c6ae1ff43ed4c6c2834c0f85e7188 Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 30 Apr 2026 07:52:08 +0000 Subject: [PATCH 33/36] chore(platform): merge platform-dev into dev (#308) Integrates latest TenantPilot platform changes from `platform-dev` into `dev`. Refresh method in this update: merge from `origin/dev` into `platform-dev` on explicit user request. This PR was created by agent on user request; do not merge automatically. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/308 --- .../skills/platform-feature-finish/SKILL.md | 625 ++++++++++++++++ .../Filament/Pages/CrossTenantComparePage.php | 674 ++++++++++++++++++ .../app/Filament/Resources/TenantResource.php | 262 +++++++ .../Providers/Filament/AdminPanelProvider.php | 2 + .../Services/Audit/WorkspaceAuditLogger.php | 37 + .../app/Support/Audit/AuditActionId.php | 3 + .../Navigation/CanonicalNavigationContext.php | 13 + .../CrossTenantComparePreviewBuilder.php | 416 +++++++++++ .../CrossTenantCompareSelection.php | 83 +++ .../CrossTenantPromotionPreflight.php | 143 ++++ .../pages/cross-tenant-compare.blade.php | 208 ++++++ .../BuildsPortfolioCompareFixtures.php | 119 ++++ .../CrossTenantCompareAuthorizationTest.php | 97 +++ .../CrossTenantCompareLaunchContextTest.php | 188 +++++ .../CrossTenantComparePageTest.php | 82 +++ ...CrossTenantPromotionPreflightAuditTest.php | 62 ++ .../CrossTenantComparePreviewBuilderTest.php | 192 +++++ .../CrossTenantPromotionPreflightTest.php | 227 ++++++ .../checklists/requirements.md | 8 +- .../plan.md | 47 +- .../spec.md | 24 +- .../tasks.md | 80 +-- 22 files changed, 3530 insertions(+), 62 deletions(-) create mode 100644 .github/skills/platform-feature-finish/SKILL.md create mode 100644 apps/platform/app/Filament/Pages/CrossTenantComparePage.php create mode 100644 apps/platform/app/Support/PortfolioCompare/CrossTenantComparePreviewBuilder.php create mode 100644 apps/platform/app/Support/PortfolioCompare/CrossTenantCompareSelection.php create mode 100644 apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionPreflight.php create mode 100644 apps/platform/resources/views/filament/pages/cross-tenant-compare.blade.php create mode 100644 apps/platform/tests/Feature/Concerns/BuildsPortfolioCompareFixtures.php create mode 100644 apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php create mode 100644 apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php create mode 100644 apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php create mode 100644 apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php create mode 100644 apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php create mode 100644 apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php diff --git a/.github/skills/platform-feature-finish/SKILL.md b/.github/skills/platform-feature-finish/SKILL.md new file mode 100644 index 00000000..a204a3b4 --- /dev/null +++ b/.github/skills/platform-feature-finish/SKILL.md @@ -0,0 +1,625 @@ + + +--- +name: platform-feature-finish +description: Commit, push, create a Gitea PR from a TenantPilot platform feature branch into platform-dev, and optionally refresh the platform-dev to dev integration PR by rebase. +--- + +# Skill: platform-feature-finish + +## Purpose + +Automate the TenantPilot platform feature completion workflow. + +Trigger this skill when the user says something like: + +- "alles committen pushen und PR gegen platform-dev" +- "feature fertig, bitte PR erstellen" +- "platform feature abschließen" +- "commit push PR mit Gitea MCP" +- "mach PR gegen platform-dev" +- "finish platform feature" +- "platform-dev nach dev vorbereiten" +- "platform-dev PR aktualisieren" +- "out-of-date mit dev beheben" +- "integration PR refresh" +- "platform-dev auf dev rebasen" + +This skill handles: + +1. Validate current Git branch +2. Commit all feature changes +3. Push current feature branch +4. Create a Gitea pull request into `platform-dev` +5. Refresh the `platform-dev` → `dev` integration PR when explicitly requested +6. Report the PR link and next integration step + +--- + +## Branch Model + +TenantPilot uses area branches: + +```text +dev = shared integration branch +platform-dev = platform/application area integration branch +website-dev = website/marketing area integration branch +``` + +For platform features: + +```text +platform-dev + ↓ +feature branch + ↓ +PR back to platform-dev + ↓ +platform-dev → dev integration PR +``` + +Rules: + +- Platform feature branches MUST target `platform-dev`. +- Do NOT target `dev` directly unless the user explicitly asks. +- Do NOT use `website-dev` for platform features. +- `platform-dev` is the default PR base for TenantPilot platform/application work. +- `dev` is the shared integration branch. + +### Solo Workflow Rule + +The user works alone on `platform-dev`. + +For refreshing the integration branch before opening or updating the PR `platform-dev` → `dev`, prefer rebase over merge. + +Do not repeatedly merge `origin/dev` into `platform-dev` for refresh. + +Avoid creating repeated merge commits like: + +```text +Merge remote-tracking branch 'origin/dev' into platform-dev +``` + +Use `--force-with-lease`, never plain `--force`. + +If rebase conflicts occur, stop and report the conflict files. + +--- + +## Preconditions + +Before committing: + +1. Confirm repository root. +2. Confirm current branch is not protected. + +Protected branches: + +```text +dev +platform-dev +website-dev +main +master +``` + +If the current branch is protected, STOP and report: + +```text +Ich bin auf einem geschützten Branch. Bitte zuerst einen Feature-Branch auschecken. +``` + +3. Confirm remote exists. +4. Confirm there are local changes, untracked files, or unpushed commits. +5. Confirm there are no unresolved conflicts. + +Do not ask for confirmation unless: + +- The current branch is protected. +- Git status indicates unresolved conflicts. +- There is no remote configured. +- `.env` or other local secret/config files would be committed. +- Commit fails. +- Push fails. +- Gitea MCP PR creation fails. + +--- + +## Required Tools + +Use terminal for Git operations. + +Use Gitea MCP for pull request creation. + +Preferred Gitea MCP operation: + +```text +create_pull_request +``` + +Required PR parameters: + +```json +{ + "owner": "ahmido", + "repo": "TenantAtlas", + "head": "", + "base": "platform-dev", + "title": "", + "body": "" +} +``` + +--- + +## Workflow + +### Step 1 — Inspect Git state + +Run: + +```bash +git rev-parse --show-toplevel +git rev-parse --abbrev-ref HEAD +git status --porcelain +git status -sb +git config --get remote.origin.url +git log --oneline --max-count=5 +``` + +Determine: + +- repository root +- current branch +- changed files +- untracked files +- remote URL +- whether there are unpushed commits +- whether unresolved conflicts exist + +If the current branch is protected, stop. + +If unresolved conflicts exist, stop. + +If no remote exists, stop. + +--- + +### Step 2 — Check for local environment files + +Before `git add -A`, check whether local environment/config files are modified or untracked: + +```bash +git status --porcelain | grep -E '(^.. \.env$|^.. apps/platform/\.env$|^.. .*\.env$)' || true +``` + +If `.env` or another environment file is included, STOP and report: + +```text +Achtung: Eine .env-/Environment-Datei ist geändert oder untracked. Ich committe das nicht automatisch. Bitte prüfen oder aus dem Commit entfernen. +``` + +Do not commit secrets or local runtime configuration. + +--- + +### Step 3 — Build commit message + +Use the current branch name. + +If branch starts with a spec number, for example: + +```text +256-external-support-desk-handoff +``` + +Generate: + +```text +feat(specs/256): external support desk handoff +``` + +If branch does not contain a spec number, generate: + +```text +feat(platform): complete +``` + +Rules: + +- Use lowercase subject. +- Use feature-style subject. +- Do not include `WIP`. +- Do not include `final`. +- Do not include overly generic `updates`. + +Examples: + +```text +feat(specs/256): external support desk handoff +feat(specs/252): platform localization v1 +feat(platform): improve tenant review workspace +``` + +--- + +### Step 4 — Commit all changes + +Run: + +```bash +git add -A +git commit -m "" +``` + +If there are no local changes to commit, continue only if the branch has unpushed commits. + +Check unpushed commits with: + +```bash +git status -sb +git log --oneline origin/..HEAD +``` + +If there are no local changes and no unpushed commits, report: + +```text +Es gibt keine lokalen Änderungen und keine unpushed commits. Ich erstelle keinen leeren Commit. +``` + +Then continue to PR creation only if the branch already exists remotely or can be pushed. + +--- + +### Step 5 — Push branch + +Run: + +```bash +git push --set-upstream origin +``` + +If the upstream already exists, this is acceptable. + +Never force-push unless the user explicitly requests it. + +--- + +### Step 6 — Create PR into platform-dev via Gitea MCP + +Use Gitea MCP to create a pull request: + +```json +{ + "owner": "ahmido", + "repo": "TenantAtlas", + "head": "", + "base": "platform-dev", + "title": "", + "body": "Implements platform feature branch ``.\n\nTarget branch: `platform-dev`.\n\nFollow-up integration path after merge:\n\n`platform-dev` → `dev`." +} +``` + +If a PR already exists for the same branch and base, do not create a duplicate. + +Report the existing PR if available. + +--- + +## Optional Step — Check platform-dev to dev PR + +After creating the feature PR, check whether an open integration PR exists: + +```text +platform-dev → dev +``` + +If a Gitea MCP list/search pull request function is available, use it. + +If one exists, report: + +```text +Der Folge-PR `platform-dev` → `dev` existiert bereits: +``` + +If none exists, report: + +```text +Nach dem Merge dieses Feature-PRs sollte der Integrations-PR `platform-dev` → `dev` erstellt oder aktualisiert werden. +``` + +Do not automatically create the `platform-dev` → `dev` PR unless the user explicitly asks for it. + +Reason: before the feature PR is merged into `platform-dev`, the integration PR may not include the new feature yet. + +--- + +## Integration Refresh Mode + +Use this mode when the user explicitly says one of the following: + +- "platform-dev nach dev vorbereiten" +- "platform-dev PR aktualisieren" +- "out-of-date mit dev beheben" +- "integration PR refresh" +- "platform-dev auf dev rebasen" +- "auch platform-dev nach dev" +- "und danach platform-dev nach dev" +- "full integration" +- "kompletten platform-dev zu dev PR machen" +- "folge-pr erstellen" + +This mode prepares or updates the integration PR: + +```text +platform-dev → dev +``` + +Because the user works alone on `platform-dev`, prefer rebase over merge. + +### Integration Refresh Preconditions + +Before running this mode: + +1. Ensure the working tree is clean. +2. Ensure there are no unresolved conflicts. +3. Fetch remote branches. +4. Ensure `origin/platform-dev` exists. +5. Ensure `origin/dev` exists. + +If the working tree is dirty, STOP and report: + +```text +Der Working Tree ist nicht sauber. Bitte erst Änderungen committen, stashen oder verwerfen, bevor `platform-dev` auf `dev` rebased wird. +``` + +If unresolved conflicts exist, STOP and report the conflict files. + +### Integration Refresh Workflow + +Run: + +```bash +git fetch origin +git checkout platform-dev +git reset --hard origin/platform-dev +git rebase origin/dev +git push --force-with-lease origin platform-dev +``` + +After pushing, verify that `origin/dev` is now an ancestor of `origin/platform-dev`: + +```bash +git fetch origin +git merge-base --is-ancestor origin/dev origin/platform-dev \ + && echo "OK: platform-dev contains dev" \ + || echo "OUTDATED: platform-dev does not contain dev" +``` + +If the verification prints `OUTDATED`, stop and report it. Do not claim the PR is up-to-date. + +Rules: + +- Do not merge `origin/dev` into `platform-dev` for this refresh. +- Do not create repeated merge commits from `origin/dev` into `platform-dev`. +- Use `git push --force-with-lease origin platform-dev` after a successful rebase. +- Never use plain `git push --force`. +- If `git rebase origin/dev` reports conflicts, stop immediately. +- Do not continue to PR creation while a rebase is unresolved. +- Do not auto-merge the PR. +- Do not claim Gitea will remove the out-of-date warning unless the ancestor check succeeds. + +If rebase conflicts occur, report: + +```text +Rebase-Konflikte erkannt. Ich habe gestoppt. + +Konfliktdateien: + + +Bitte Konflikte lösen, dann `git rebase --continue` ausführen oder den Rebase mit `git rebase --abort` abbrechen. +``` + +### Create or Report Integration PR + +After the rebase, push, and ancestor verification succeeded, use Gitea MCP to create or report the integration PR: + +```json +{ + "owner": "ahmido", + "repo": "TenantAtlas", + "head": "platform-dev", + "base": "dev", + "title": "chore(platform): merge platform-dev into dev", + "body": "Integrates latest TenantPilot platform changes from `platform-dev` into `dev`.\n\nThis PR was created by agent on user request; do not merge automatically." +} +``` + +If an open PR already exists for `platform-dev` → `dev`, do not create a duplicate. Report the existing PR. + +### Integration Refresh Reporting Format + +Final response for this mode must include: + +```text +Fertig. + +- Branch aktualisiert: platform-dev +- Refresh-Methode: rebase auf origin/dev +- Ancestor-Check: origin/dev ist Ancestor von origin/platform-dev +- Push: --force-with-lease origin/platform-dev +- Integration PR: +- Base: dev +- Hinweis: PR wurde nicht automatisch gemerged. +``` + +Do not claim tests passed unless they were actually executed. + +--- + +## Reporting Format + +Final response must be concise and include: + +```text +Fertig. + +- Branch: +- Commit: +- Push: origin/ +- PR: +- Base: platform-dev +- Nächster Schritt: Nach Merge `platform-dev` → `dev` PR aktualisieren/erstellen +``` + +If tests were not run, say: + +```text +Tests wurden in diesem Skill nicht automatisch ausgeführt. +``` + +Do not claim tests passed unless the tool actually ran them. + +--- + +## Safety Rules + +- Never commit directly to `dev`, `platform-dev`, `website-dev`, `main`, or `master`. +- Never force-push unless explicitly requested. +- For Integration Refresh Mode only, `git push --force-with-lease origin platform-dev` is allowed because the user works alone on `platform-dev`; never use plain `--force`. +- Never auto-merge PRs unless explicitly requested. +- Never target `dev` directly for platform feature PRs unless explicitly requested. +- Never delete branches unless explicitly requested. +- Never claim tests were run unless the tool actually ran them. +- Never commit `.env`, secrets, local tokens, local mock-server configuration, or temporary runtime-only changes. +- If migrations were created, mention that the target environment needs migration execution after deployment. +- If unresolved conflicts exist, stop. + +--- + +## Useful Commands + +Inspect: + +```bash +git rev-parse --show-toplevel +git rev-parse --abbrev-ref HEAD +git status --porcelain +git status -sb +git config --get remote.origin.url +``` + +Detect protected branch: + +```bash +branch="$(git rev-parse --abbrev-ref HEAD)" +case "$branch" in + dev|platform-dev|website-dev|main|master) + echo "PROTECTED_BRANCH:$branch" + exit 2 + ;; +esac +``` + +Detect unresolved conflicts: + +```bash +git diff --name-only --diff-filter=U +``` + +Detect `.env` changes: + +```bash +git status --porcelain | grep -E '(^.. \.env$|^.. apps/platform/\.env$|^.. .*\.env$)' || true +``` + +Commit: + +```bash +git add -A +git commit -m "" +``` + +Push: + +```bash +git push --set-upstream origin "$(git rev-parse --abbrev-ref HEAD)" +``` + +Latest commit: + +```bash +git rev-parse --short HEAD +git log -1 --pretty=%s +``` + +Integration refresh: + +```bash +git fetch origin +git checkout platform-dev +git reset --hard origin/platform-dev +git rebase origin/dev +git push --force-with-lease origin platform-dev +``` + +Verify integration refresh: + +```bash +git fetch origin +git merge-base --is-ancestor origin/dev origin/platform-dev \ + && echo "OK: platform-dev contains dev" \ + || echo "OUTDATED: platform-dev does not contain dev" +``` + +Check rebase conflicts: + +```bash +git diff --name-only --diff-filter=U +``` + +--- + +## Example User Request + +User: + +```text +alles committen pushen und pr gegen platform-dev mit gitea mcp +``` + +Assistant should: + +1. Check current branch. +2. Stop if branch is protected. +3. Stop if `.env` or secrets would be committed. +4. Commit all changes. +5. Push current branch. +6. Create PR into `platform-dev` with Gitea MCP. +7. Report result. + +Do not ask unnecessary follow-up questions. + +--- + +## Example Integration Refresh Request + +User: + +```text +platform-dev PR aktualisieren +``` + +Assistant should: + +1. Ensure the working tree is clean. +2. Fetch origin. +3. Checkout `platform-dev`. +4. Reset local `platform-dev` to `origin/platform-dev`. +5. Rebase `platform-dev` onto `origin/dev`. +6. Push with `--force-with-lease`. +7. Verify `origin/dev` is an ancestor of `origin/platform-dev`. +8. Create or report the PR `platform-dev` → `dev`. +9. Report result. + +Do not merge the PR automatically. \ No newline at end of file diff --git a/apps/platform/app/Filament/Pages/CrossTenantComparePage.php b/apps/platform/app/Filament/Pages/CrossTenantComparePage.php new file mode 100644 index 00000000..5de3ac21 --- /dev/null +++ b/apps/platform/app/Filament/Pages/CrossTenantComparePage.php @@ -0,0 +1,674 @@ + + */ + public array $selectedPolicyTypes = []; + + /** + * @var array|null + */ + public ?array $navigationContextPayload = null; + + /** + * @var array|null + */ + public ?array $preview = null; + + /** + * @var array|null + */ + public ?array $preflight = null; + + public ?string $selectionMessage = null; + + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions preserve return navigation, tenant drill-downs, and one dominant promotion-preflight action.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'The compare page uses explicit selection controls instead of row-click inspection.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Cross-tenant compare renders focused subject summaries instead of row-level overflow actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The compare page has no bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The compare page explains when a selection is incomplete or invalid before any preview exists.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'Cross-tenant compare is a workspace decision page, not a record detail header.'); + } + + public function mount(): void + { + $this->authorizePageAccess(); + + $this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null; + + $this->hydrateSelectionFromRequest(); + $this->refreshPreview(); + + $this->form->fill($this->formState()); + } + + public function form(Schema $schema): Schema + { + return $schema + ->schema([ + Grid::make([ + 'default' => 1, + 'xl' => 3, + ]) + ->schema([ + Select::make('sourceTenantId') + ->label('Source tenant') + ->options(fn (): array => $this->tenantOptions()) + ->searchable() + ->preload() + ->native(false) + ->placeholder('Select a source tenant') + ->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-source']) + ->extraInputAttributes(['data-testid' => 'cross-tenant-source']), + Select::make('targetTenantId') + ->label('Target tenant') + ->options(fn (): array => $this->tenantOptions()) + ->searchable() + ->preload() + ->native(false) + ->placeholder('Select a target tenant') + ->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-target']) + ->extraInputAttributes(['data-testid' => 'cross-tenant-target']), + Select::make('selectedPolicyTypes') + ->label('Governed subjects') + ->options(fn (): array => $this->policyTypeOptions()) + ->multiple() + ->searchable() + ->preload() + ->native(false) + ->placeholder('All governed subjects') + ->helperText(fn (): ?string => $this->policyTypeOptions() === [] + ? 'Governed subject filters appear after authorized tenant inventory exists in the active workspace.' + : null) + ->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-policy-types']) + ->extraInputAttributes(['data-testid' => 'cross-tenant-policy-types']), + ]), + ]); + } + + /** + * @return array + */ + protected function getHeaderActions(): array + { + $actions = []; + + $navigationContext = $this->navigationContext(); + + if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) { + $actions[] = Action::make('return_to_origin') + ->label($navigationContext->backLinkLabel) + ->icon('heroicon-o-arrow-left') + ->color('gray') + ->url($navigationContext->backLinkUrl); + } + + $sourceTenant = $this->selectedSourceTenant(); + + if ($sourceTenant instanceof Tenant) { + $actions[] = Action::make('open_source_tenant') + ->label('Open source tenant') + ->icon('heroicon-o-arrow-top-right-on-square') + ->color('gray') + ->url(TenantResource::getUrl('view', ['record' => $sourceTenant], panel: 'admin')); + } + + $targetTenant = $this->selectedTargetTenant(); + + if ($targetTenant instanceof Tenant) { + $actions[] = Action::make('open_target_tenant') + ->label('Open target tenant') + ->icon('heroicon-o-arrow-top-right-on-square') + ->color('gray') + ->url(TenantResource::getUrl('view', ['record' => $targetTenant], panel: 'admin')); + } + + $preflightAction = Action::make('generatePromotionPreflight') + ->label('Generate promotion preflight') + ->icon('heroicon-o-sparkles') + ->color('primary') + ->disabled(fn (): bool => $this->preflightDisabledReason() !== null) + ->tooltip(fn (): ?string => $this->preflightDisabledReason()) + ->action(fn (): mixed => $this->generatePromotionPreflight()); + + $preflightAction = WorkspaceUiEnforcement::forAction( + $preflightAction, + fn (): ?Workspace => $this->workspace(), + ) + ->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE) + ->preserveDisabled() + ->tooltip('You need workspace baseline manage access to generate a promotion preflight.') + ->apply() + ->tooltip(function (): ?string { + $user = auth()->user(); + $workspace = $this->workspace(); + + if ($user instanceof User && $workspace instanceof Workspace) { + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if ($resolver->isMember($user, $workspace) + && ! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) { + return 'You need workspace baseline manage access to generate a promotion preflight.'; + } + } + + return $this->preflightDisabledReason(); + }); + + $actions[] = $preflightAction; + + return $actions; + } + + public function applySelection(): void + { + $this->selectionMessage = null; + $this->preflight = null; + + $this->sourceTenantId = $this->normalizeTenantIdentifier($this->sourceTenantId); + $this->targetTenantId = $this->normalizeTenantIdentifier($this->targetTenantId); + $this->selectedPolicyTypes = $this->normalizePolicyTypes($this->selectedPolicyTypes); + + if ($this->sourceTenantId !== null + && $this->targetTenantId !== null + && $this->sourceTenantId === $this->targetTenantId) { + $this->selectionMessage = 'Choose two different tenants.'; + $this->addError('targetTenantId', $this->selectionMessage); + + return; + } + + $this->redirect($this->selectionUrl(), navigate: true); + } + + public function generatePromotionPreflight(): void + { + $this->authorizePageAccess(); + $this->authorizePreflightExecution(); + + if ($this->preview === null) { + $this->refreshPreview(); + } + + if ($this->preview === null) { + return; + } + + $selection = $this->compareSelection(); + + if (! $selection instanceof CrossTenantCompareSelection) { + return; + } + + $this->preflight = app(CrossTenantPromotionPreflight::class)->build($this->preview); + + $workspace = $this->workspace(); + $user = auth()->user(); + + if ($workspace instanceof Workspace && $user instanceof User) { + app(WorkspaceAuditLogger::class)->logCrossTenantPromotionPreflightGenerated( + workspace: $workspace, + sourceTenant: $selection->sourceTenant, + targetTenant: $selection->targetTenant, + preflight: $this->preflight, + actor: $user, + ); + } + } + + public function clearSelectionUrl(): string + { + return static::getUrl($this->routeParameters([ + self::SOURCE_TENANT_QUERY_KEY => null, + self::TARGET_TENANT_QUERY_KEY => null, + self::POLICY_TYPE_QUERY_KEY => null, + ]), panel: 'admin'); + } + + public function selectionUrl(): string + { + return static::getUrl($this->routeParameters(), panel: 'admin'); + } + + public static function launchUrl( + ?Tenant $sourceTenant = null, + ?Tenant $targetTenant = null, + ?CanonicalNavigationContext $navigationContext = null, + ): string { + $parameters = []; + + if ($sourceTenant instanceof Tenant) { + $parameters[self::SOURCE_TENANT_QUERY_KEY] = (int) $sourceTenant->getKey(); + } + + if ($targetTenant instanceof Tenant) { + $parameters[self::TARGET_TENANT_QUERY_KEY] = (int) $targetTenant->getKey(); + } + + if ($navigationContext instanceof CanonicalNavigationContext) { + $parameters = array_replace($parameters, $navigationContext->toQuery()); + } + + return static::getUrl($parameters, panel: 'admin'); + } + + public function hasActiveSelection(): bool + { + return $this->sourceTenantId !== null + || $this->targetTenantId !== null + || $this->selectedPolicyTypes !== []; + } + + public function stateColor(string $state): string + { + return match ($state) { + 'match', 'ready' => 'success', + 'different', 'manual_mapping_required' => 'warning', + 'missing' => 'info', + 'ambiguous' => 'gray', + 'blocked' => 'danger', + default => 'gray', + }; + } + + public function stateLabel(string $value): string + { + return Str::headline(str_replace('_', ' ', $value)); + } + + public function reasonLabel(string $reasonCode): string + { + return Str::headline(str_replace('_', ' ', $reasonCode)); + } + + public function sourceTenantUrl(): ?string + { + $tenant = $this->selectedSourceTenant(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'); + } + + public function targetTenantUrl(): ?string + { + $tenant = $this->selectedTargetTenant(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'); + } + + /** + * @return array + */ + private function formState(): array + { + return [ + 'sourceTenantId' => $this->sourceTenantId, + 'targetTenantId' => $this->targetTenantId, + 'selectedPolicyTypes' => $this->selectedPolicyTypes, + ]; + } + + private function hydrateSelectionFromRequest(): void + { + $this->sourceTenantId = $this->normalizeTenantIdentifier(request()->query(self::SOURCE_TENANT_QUERY_KEY)); + $this->targetTenantId = $this->normalizeTenantIdentifier(request()->query(self::TARGET_TENANT_QUERY_KEY)); + $this->selectedPolicyTypes = $this->normalizePolicyTypes(request()->query(self::POLICY_TYPE_QUERY_KEY, [])); + } + + private function refreshPreview(): void + { + $this->selectionMessage = null; + $this->preview = null; + $this->preflight = null; + + $selection = $this->compareSelection(); + + if (! $selection instanceof CrossTenantCompareSelection) { + return; + } + + $this->preview = app(CrossTenantComparePreviewBuilder::class)->build($selection); + } + + private function authorizePageAccess(): void + { + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User) { + abort(403); + } + + if (! $workspace instanceof Workspace) { + abort(404); + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + abort(404); + } + + if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) { + abort(403); + } + } + + private function authorizePreflightExecution(): void + { + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User) { + abort(403); + } + + if (! $workspace instanceof Workspace) { + abort(404); + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + abort(404); + } + + if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) { + abort(403); + } + } + + private function compareSelection(): ?CrossTenantCompareSelection + { + $sourceTenant = $this->selectedSourceTenant(); + $targetTenant = $this->selectedTargetTenant(); + + if (! $sourceTenant instanceof Tenant || ! $targetTenant instanceof Tenant) { + return null; + } + + if ((int) $sourceTenant->getKey() === (int) $targetTenant->getKey()) { + $this->selectionMessage = 'Choose two different tenants.'; + + return null; + } + + return new CrossTenantCompareSelection( + sourceTenant: $sourceTenant, + targetTenant: $targetTenant, + policyTypes: $this->selectedPolicyTypes, + ); + } + + private function selectedSourceTenant(): ?Tenant + { + if ($this->sourceTenantId === null) { + return null; + } + + return $this->resolveAuthorizedTenant($this->sourceTenantId); + } + + private function selectedTargetTenant(): ?Tenant + { + if ($this->targetTenantId === null) { + return null; + } + + return $this->resolveAuthorizedTenant($this->targetTenantId); + } + + private function resolveAuthorizedTenant(string $tenantId): Tenant + { + $workspace = $this->workspace(); + $user = auth()->user(); + + if (! $workspace instanceof Workspace || ! $user instanceof User) { + abort(404); + } + + $tenant = Tenant::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->whereKey((int) $tenantId) + ->first(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $user->canAccessTenant($tenant) || ! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) { + abort(404); + } + + return $tenant; + } + + /** + * @return array + */ + private function tenantOptions(): array + { + $workspace = $this->workspace(); + $user = auth()->user(); + + if (! $workspace instanceof Workspace || ! $user instanceof User) { + return []; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + $tenants = $user->tenants() + ->where('tenants.workspace_id', (int) $workspace->getKey()) + ->select('tenants.*') + ->orderBy('tenants.name') + ->get(); + + $resolver->primeMemberships($user, $tenants->modelKeys()); + + return $tenants + ->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) + ->mapWithKeys(fn (Tenant $tenant): array => [ + (string) $tenant->getKey() => (string) $tenant->name, + ]) + ->all(); + } + + /** + * @return array + */ + private function policyTypeOptions(): array + { + $tenantIds = array_map(static fn (string $tenantId): int => (int) $tenantId, array_keys($this->tenantOptions())); + + if ($tenantIds === []) { + return []; + } + + return InventoryItem::query() + ->whereIn('tenant_id', $tenantIds) + ->whereNotNull('policy_type') + ->where('policy_type', '!=', '') + ->distinct() + ->orderBy('policy_type') + ->pluck('policy_type') + ->mapWithKeys(fn (string $policyType): array => [ + $policyType => Str::headline($policyType), + ]) + ->all(); + } + + private function preflightDisabledReason(): ?string + { + if ($this->selectionMessage !== null) { + return $this->selectionMessage; + } + + if (! is_array($this->preview)) { + return 'Select an authorized source and target tenant to generate a promotion preflight.'; + } + + if ((int) data_get($this->preview, 'summary.total', 0) === 0) { + return 'No governed subjects are available for this compare selection yet.'; + } + + return null; + } + + /** + * @param mixed $value + */ + private function normalizeTenantIdentifier(mixed $value): ?string + { + if (! is_string($value) && ! is_int($value)) { + return null; + } + + $normalized = trim((string) $value); + + return is_numeric($normalized) && (int) $normalized > 0 ? (string) (int) $normalized : null; + } + + /** + * @param mixed $value + * @return list + */ + private function normalizePolicyTypes(mixed $value): array + { + $allowed = array_fill_keys(array_keys($this->policyTypeOptions()), true); + + $values = match (true) { + is_string($value) && $value !== '' => [$value], + is_array($value) => $value, + default => [], + }; + + return array_values(array_filter(array_unique(array_map( + static fn (mixed $item): string => is_string($item) ? trim($item) : '', + $values, + )), static fn (string $item): bool => $item !== '' && isset($allowed[$item]))); + } + + /** + * @param array $overrides + * @return array + */ + private function routeParameters(array $overrides = []): array + { + $parameters = [ + self::SOURCE_TENANT_QUERY_KEY => $this->sourceTenantId, + self::TARGET_TENANT_QUERY_KEY => $this->targetTenantId, + self::POLICY_TYPE_QUERY_KEY => $this->selectedPolicyTypes, + ]; + + if (is_array($this->navigationContextPayload)) { + $parameters['nav'] = $this->navigationContextPayload; + } + + foreach ($overrides as $key => $value) { + $parameters[$key] = $value; + } + + return array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []); + } + + private function navigationContext(): ?CanonicalNavigationContext + { + if (! is_array($this->navigationContextPayload)) { + return CanonicalNavigationContext::fromRequest(request()); + } + + return CanonicalNavigationContext::fromPayload($this->navigationContextPayload); + } + + private function workspace(): ?Workspace + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId)) { + return null; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + return $workspace instanceof Workspace ? $workspace : null; + } +} \ No newline at end of file diff --git a/apps/platform/app/Filament/Resources/TenantResource.php b/apps/platform/app/Filament/Resources/TenantResource.php index 8971e621..53963a29 100644 --- a/apps/platform/app/Filament/Resources/TenantResource.php +++ b/apps/platform/app/Filament/Resources/TenantResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Filament\Pages\CrossTenantComparePage; use App\Filament\Pages\TenantDashboard; use App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource\RelationManagers; @@ -15,9 +16,11 @@ use App\Models\TenantOnboardingSession; use App\Models\TenantTriageReview; use App\Models\User; +use App\Models\Workspace; use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Auth\CapabilityResolver; use App\Services\Auth\RoleCapabilityMap; +use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Directory\RoleDefinitionsSyncService; use App\Services\Graph\GraphClientInterface; @@ -44,6 +47,7 @@ use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\Navigation\CanonicalNavigationContext; use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\TenantTriageReviewStateResolver; use App\Support\Rbac\UiEnforcement; @@ -68,6 +72,7 @@ use Filament\Actions; use Filament\Actions\ActionGroup; use Filament\Actions\BulkActionGroup; +use Filament\Facades\Filament; use Filament\Forms; use Filament\Infolists; use Filament\Notifications\Notification; @@ -824,6 +829,27 @@ public static function table(Table $table): Table ->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding')) ->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor && static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'), + Actions\Action::make('compareTenants') + ->label('Compare tenants') + ->icon('heroicon-o-scale') + ->color('gray') + ->url(function (Tenant $record, mixed $livewire): string { + $triageState = $livewire instanceof Pages\ListTenants + ? static::currentPortfolioTriageState($livewire) + : []; + + if (! static::hasActivePortfolioTriageState( + static::sanitizeBackupPostures($triageState['backup_posture'] ?? []), + static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []), + static::sanitizeReviewStates($triageState['review_state'] ?? []), + static::sanitizeTriageSort($triageState['triage_sort'] ?? null), + )) { + $triageState = static::portfolioReturnFiltersFromRequest(request()->query()); + } + + return static::crossTenantCompareOpenUrl($record, $triageState); + }) + ->visible(fn (Tenant $record): bool => static::crossTenantCompareActionVisible($record)), UiEnforcement::forAction( Actions\Action::make('edit') ->label('Edit') @@ -966,6 +992,34 @@ public static function table(Table $table): Table ]) ->bulkActions([ BulkActionGroup::make([ + Actions\BulkAction::make('compareSelected') + ->label('Compare selected') + ->icon('heroicon-o-scale') + ->color('gray') + ->visible(fn (): bool => auth()->user() instanceof User) + ->authorize(fn (): bool => auth()->user() instanceof User) + ->extraAttributes(fn (mixed $livewire): array => [ + 'x-bind:aria-disabled' => static::crossTenantCompareBulkClientDisabledExpression($livewire).' ? true : null', + 'x-bind:disabled' => static::crossTenantCompareBulkClientDisabledExpression($livewire), + 'x-bind:title' => static::crossTenantCompareBulkClientTooltipExpression($livewire), + 'x-bind:class' => "{ 'fi-disabled': ".static::crossTenantCompareBulkClientDisabledExpression($livewire).' }', + ]) + ->action(function (Collection $records, mixed $livewire): void { + $disabledReason = static::crossTenantCompareBulkDisabledReason($records); + + if ($disabledReason !== null) { + Notification::make() + ->title($disabledReason) + ->danger() + ->send(); + + return; + } + + if (method_exists($livewire, 'redirect')) { + $livewire->redirect(static::crossTenantCompareBulkOpenUrl($records, $livewire), navigate: true); + } + }), Actions\BulkAction::make('syncSelected') ->label('Sync selected') ->icon('heroicon-o-arrow-path') @@ -1158,6 +1212,52 @@ public static function tenantDashboardOpenUrl(Tenant $record, array $triageState ); } + /** + * @param array{ + * backup_posture?: list, + * recovery_evidence?: list, + * review_state?: list, + * triage_sort?: string|null + * } $triageState + */ + public static function crossTenantCompareOpenUrl(Tenant $record, array $triageState = []): string + { + return static::crossTenantCompareOpenUrlForSelection( + targetTenant: $record, + triageState: $triageState, + ); + } + + /** + * @param array{ + * backup_posture?: list, + * recovery_evidence?: list, + * review_state?: list, + * triage_sort?: string|null + * } $triageState + */ + public static function crossTenantCompareOpenUrlForSelection( + Tenant $targetTenant, + array $triageState = [], + ?Tenant $sourceTenant = null, + ): string { + $normalizedState = static::portfolioReturnFilters( + static::sanitizeBackupPostures($triageState['backup_posture'] ?? []), + static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []), + static::sanitizeReviewStates($triageState['review_state'] ?? []), + static::sanitizeTriageSort($triageState['triage_sort'] ?? null), + ); + + return CrossTenantComparePage::launchUrl( + sourceTenant: $sourceTenant, + targetTenant: $targetTenant, + navigationContext: CanonicalNavigationContext::forTenantRegistry( + backLinkUrl: static::getUrl(panel: 'admin', parameters: $normalizedState), + tenantId: $sourceTenant instanceof Tenant ? null : (int) $targetTenant->getKey(), + ), + ); + } + /** * @param array{ * backup_posture?: list, @@ -1248,6 +1348,168 @@ private static function portfolioReturnFiltersFromRequest(array $query): array ); } + private static function crossTenantCompareActionVisible(Tenant $record): bool + { + if (! $record->isActive()) { + return false; + } + + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId) || $workspaceId !== (int) $record->workspace_id) { + return false; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return false; + } + + /** @var WorkspaceCapabilityResolver $workspaceResolver */ + $workspaceResolver = app(WorkspaceCapabilityResolver::class); + + if (! $workspaceResolver->isMember($user, $workspace) + || ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) { + return false; + } + + /** @var CapabilityResolver $tenantResolver */ + $tenantResolver = app(CapabilityResolver::class); + + return $user->canAccessTenant($record) + && $tenantResolver->can($user, $record, Capabilities::TENANT_VIEW); + } + + private static function crossTenantCompareBulkDisabledReason(Collection $records): ?string + { + $user = auth()->user(); + + if (! $user instanceof User) { + return UiTooltips::insufficientPermission(); + } + + $tenants = $records + ->filter(fn ($record): bool => $record instanceof Tenant) + ->values(); + + if ($records->count() !== 2 || $tenants->count() !== 2) { + return 'Select exactly two tenants to compare.'; + } + + if ($tenants->contains(fn (Tenant $tenant): bool => ! $tenant->isActive())) { + return 'Only active tenants can be compared.'; + } + + $workspaceIds = $tenants + ->map(fn (Tenant $tenant): int => (int) $tenant->workspace_id) + ->unique() + ->values(); + + if ($workspaceIds->count() !== 1) { + return UiTooltips::insufficientPermission(); + } + + $workspaceId = $workspaceIds->first(); + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return UiTooltips::insufficientPermission(); + } + + /** @var WorkspaceCapabilityResolver $workspaceResolver */ + $workspaceResolver = app(WorkspaceCapabilityResolver::class); + + if (! $workspaceResolver->isMember($user, $workspace) + || ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) { + return UiTooltips::insufficientPermission(); + } + + /** @var CapabilityResolver $tenantResolver */ + $tenantResolver = app(CapabilityResolver::class); + + $isDenied = $tenants->contains(fn (Tenant $tenant): bool => ! $user->canAccessTenant($tenant) + || ! $tenantResolver->can($user, $tenant, Capabilities::TENANT_VIEW)); + + return $isDenied ? UiTooltips::insufficientPermission() : null; + } + + private static function crossTenantCompareBulkClientDisabledExpression(mixed $livewire): string + { + $containsInactiveSelection = static::crossTenantCompareBulkContainsInactiveSelectionExpression($livewire); + + return "getSelectedRecordsCount() !== 2 || {$containsInactiveSelection}"; + } + + private static function crossTenantCompareBulkClientTooltipExpression(mixed $livewire): string + { + $containsInactiveSelection = static::crossTenantCompareBulkContainsInactiveSelectionExpression($livewire); + + return "getSelectedRecordsCount() !== 2 ? 'Select exactly two tenants to compare.' : ({$containsInactiveSelection} ? 'Only active tenants can be compared.' : null)"; + } + + private static function crossTenantCompareBulkContainsInactiveSelectionExpression(mixed $livewire): string + { + $inactiveRecordKeys = \Illuminate\Support\Js::from(static::crossTenantCompareInactiveSelectionRecordKeys($livewire)); + + return "[...selectedRecords].some((key) => {$inactiveRecordKeys}.includes(key))"; + } + + /** + * @return list + */ + private static function crossTenantCompareInactiveSelectionRecordKeys(mixed $livewire): array + { + if (! $livewire instanceof HasTable || ! method_exists($livewire, 'getTableRecordKey')) { + return []; + } + + $tableRecords = $livewire->getTableRecords(); + + if (method_exists($tableRecords, 'getCollection')) { + $tableRecords = $tableRecords->getCollection(); + } + + return collect($tableRecords) + ->filter(fn ($record): bool => $record instanceof Tenant && ! $record->isActive()) + ->map(fn (Tenant $tenant): string => (string) $livewire->getTableRecordKey($tenant)) + ->values() + ->all(); + } + + private static function crossTenantCompareBulkOpenUrl(Collection $records, mixed $livewire): string + { + $triageState = $livewire instanceof Pages\ListTenants + ? static::currentPortfolioTriageState($livewire) + : []; + + if (! static::hasActivePortfolioTriageState( + static::sanitizeBackupPostures($triageState['backup_posture'] ?? []), + static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []), + static::sanitizeReviewStates($triageState['review_state'] ?? []), + static::sanitizeTriageSort($triageState['triage_sort'] ?? null), + )) { + $triageState = static::portfolioReturnFiltersFromRequest(request()->query()); + } + + $tenants = $records + ->filter(fn ($record): bool => $record instanceof Tenant) + ->values(); + + return static::crossTenantCompareOpenUrlForSelection( + targetTenant: $tenants->get(1), + triageState: $triageState, + sourceTenant: $tenants->get(0), + ); + } + private static function hasActivePortfolioTriageState( array $backupPostures, array $recoveryEvidence, diff --git a/apps/platform/app/Providers/Filament/AdminPanelProvider.php b/apps/platform/app/Providers/Filament/AdminPanelProvider.php index 602bfe4f..3f29f388 100644 --- a/apps/platform/app/Providers/Filament/AdminPanelProvider.php +++ b/apps/platform/app/Providers/Filament/AdminPanelProvider.php @@ -5,6 +5,7 @@ use App\Filament\Pages\Auth\Login; use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseWorkspace; +use App\Filament\Pages\CrossTenantComparePage; use App\Filament\Pages\Findings\FindingsHygieneReport; use App\Filament\Pages\Findings\FindingsIntakeQueue; use App\Filament\Pages\Governance\GovernanceInbox; @@ -181,6 +182,7 @@ public function panel(Panel $panel): Panel InventoryCoverage::class, TenantRequiredPermissions::class, WorkspaceSettings::class, + CrossTenantComparePage::class, GovernanceInbox::class, FindingsHygieneReport::class, FindingsIntakeQueue::class, diff --git a/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php b/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php index 51b14aef..208fe080 100644 --- a/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php +++ b/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php @@ -139,6 +139,43 @@ public function logSupportDiagnosticsOpened( ); } + /** + * @param array $preflight + */ + public function logCrossTenantPromotionPreflightGenerated( + Workspace $workspace, + Tenant $sourceTenant, + Tenant $targetTenant, + array $preflight, + User|PlatformUser|null $actor = null, + ): \App\Models\AuditLog { + $summary = is_array($preflight['summary'] ?? null) ? $preflight['summary'] : []; + + return $this->log( + workspace: $workspace, + action: AuditActionId::CrossTenantPromotionPreflightGenerated, + context: [ + 'source_tenant_id' => (int) $sourceTenant->getKey(), + 'source_tenant_name' => (string) $sourceTenant->name, + 'target_tenant_id' => (int) $targetTenant->getKey(), + 'target_tenant_name' => (string) $targetTenant->name, + 'ready_count' => (int) ($summary['ready'] ?? 0), + 'blocked_count' => (int) ($summary['blocked'] ?? 0), + 'manual_mapping_required_count' => (int) ($summary['manual_mapping_required'] ?? 0), + 'total_count' => (int) ($summary['total'] ?? 0), + 'blocked_reason_counts' => is_array($preflight['blockedReasonCounts'] ?? null) + ? $preflight['blockedReasonCounts'] + : [], + ], + actor: $actor, + status: 'success', + resourceType: 'cross_tenant_promotion_preflight', + resourceId: sprintf('%s:%s', $sourceTenant->getKey(), $targetTenant->getKey()), + targetLabel: $sourceTenant->name.' -> '.$targetTenant->name, + summary: 'Cross-tenant promotion preflight generated for '.$sourceTenant->name.' -> '.$targetTenant->name, + ); + } + public function logSupportRequestCreated( SupportRequest $supportRequest, User|PlatformUser|null $actor = null, diff --git a/apps/platform/app/Support/Audit/AuditActionId.php b/apps/platform/app/Support/Audit/AuditActionId.php index 5dd9bd45..efdbe3fb 100644 --- a/apps/platform/app/Support/Audit/AuditActionId.php +++ b/apps/platform/app/Support/Audit/AuditActionId.php @@ -69,6 +69,7 @@ enum AuditActionId: string case BaselineCompareStarted = 'baseline_compare.started'; case BaselineCompareCompleted = 'baseline_compare.completed'; case BaselineCompareFailed = 'baseline_compare.failed'; + case CrossTenantPromotionPreflightGenerated = 'cross_tenant_promotion_preflight.generated'; case BaselineAssignmentCreated = 'baseline_assignment.created'; case BaselineAssignmentUpdated = 'baseline_assignment.updated'; case BaselineAssignmentDeleted = 'baseline_assignment.deleted'; @@ -218,6 +219,7 @@ private static function labels(): array self::BaselineCompareStarted->value => 'Baseline compare started', self::BaselineCompareCompleted->value => 'Baseline compare completed', self::BaselineCompareFailed->value => 'Baseline compare failed', + self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated', self::BaselineAssignmentCreated->value => 'Baseline assignment created', self::BaselineAssignmentUpdated->value => 'Baseline assignment updated', self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted', @@ -312,6 +314,7 @@ private static function summaries(): array self::BaselineProfileUpdated->value => 'Baseline profile updated', self::BaselineProfileArchived->value => 'Baseline profile archived', self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled', + self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated', self::AlertDestinationCreated->value => 'Alert destination created', self::AlertDestinationUpdated->value => 'Alert destination updated', self::AlertDestinationDeleted->value => 'Alert destination deleted', diff --git a/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php b/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php index 66b33c15..333a1f76 100644 --- a/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php +++ b/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php @@ -5,8 +5,10 @@ namespace App\Support\Navigation; use App\Filament\Pages\BaselineCompareMatrix; +use App\Filament\Resources\TenantResource\Pages\ListTenants; use App\Models\BaselineProfile; use App\Models\Tenant; +use Filament\Facades\Filament; use Illuminate\Http\Request; final readonly class CanonicalNavigationContext @@ -82,6 +84,17 @@ public static function forGovernanceInbox( ); } + public static function forTenantRegistry(string $backLinkUrl, ?int $tenantId = null): self + { + return new self( + sourceSurface: 'tenant_registry', + canonicalRouteName: ListTenants::getRouteName(Filament::getPanel('admin')), + tenantId: $tenantId, + backLinkLabel: 'Back to tenant registry', + backLinkUrl: $backLinkUrl, + ); + } + /** * @return array */ diff --git a/apps/platform/app/Support/PortfolioCompare/CrossTenantComparePreviewBuilder.php b/apps/platform/app/Support/PortfolioCompare/CrossTenantComparePreviewBuilder.php new file mode 100644 index 00000000..6232faa7 --- /dev/null +++ b/apps/platform/app/Support/PortfolioCompare/CrossTenantComparePreviewBuilder.php @@ -0,0 +1,416 @@ + + * }, + * summary: array{match: int, different: int, missing: int, ambiguous: int, blocked: int, total: int}, + * subjects: list> + * } + */ + public function build(CrossTenantCompareSelection $selection): array + { + $sourceIndex = $this->indexTenantSubjects($selection->sourceTenant, $selection->policyTypes); + $targetIndex = $this->indexTenantSubjects($selection->targetTenant, $selection->policyTypes); + + $sourceEvidence = $this->resolvedEvidenceMap($selection->sourceTenant, $sourceIndex['evidence_subjects']); + $targetEvidence = $this->resolvedEvidenceMap($selection->targetTenant, $targetIndex['evidence_subjects']); + + $subjects = []; + $summary = [ + 'match' => 0, + 'different' => 0, + 'missing' => 0, + 'ambiguous' => 0, + 'blocked' => 0, + 'total' => 0, + ]; + + foreach ($sourceIndex['preview_subjects'] as $sourceSubject) { + $previewSubject = $this->buildPreviewSubject( + sourceSubject: $sourceSubject, + sourceTenant: $selection->sourceTenant, + targetTenant: $selection->targetTenant, + targetIndex: $targetIndex['subjects'], + sourceEvidence: $sourceEvidence, + targetEvidence: $targetEvidence, + ); + + $subjects[] = $previewSubject; + $summary[$previewSubject['state']]++; + $summary['total']++; + } + + usort($subjects, function (array $left, array $right): int { + $policyTypeComparison = strcmp((string) ($left['policyType'] ?? ''), (string) ($right['policyType'] ?? '')); + + if ($policyTypeComparison !== 0) { + return $policyTypeComparison; + } + + $displayNameComparison = strcmp( + Str::lower((string) ($left['displayName'] ?? '')), + Str::lower((string) ($right['displayName'] ?? '')), + ); + + if ($displayNameComparison !== 0) { + return $displayNameComparison; + } + + return strcmp((string) ($left['subjectKey'] ?? ''), (string) ($right['subjectKey'] ?? '')); + }); + + return [ + 'selection' => [ + 'workspaceId' => $selection->workspaceId(), + 'sourceTenantId' => $selection->sourceTenantId(), + 'sourceTenantName' => (string) $selection->sourceTenant->name, + 'targetTenantId' => $selection->targetTenantId(), + 'targetTenantName' => (string) $selection->targetTenant->name, + 'policyTypes' => $selection->policyTypes, + ], + 'summary' => $summary, + 'subjects' => $subjects, + ]; + } + + /** + * @param Tenant $tenant + * @param list $policyTypes + * @return array{ + * preview_subjects: list>, + * evidence_subjects: list, + * subjects: array> + * } + */ + private function indexTenantSubjects(Tenant $tenant, array $policyTypes): array + { + $inventoryItems = InventoryItem::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->when( + $policyTypes !== [], + fn ($query) => $query->whereIn('policy_type', $policyTypes), + ) + ->orderBy('policy_type') + ->orderBy('display_name') + ->orderBy('id') + ->get(); + + $subjects = []; + $previewSubjects = []; + $evidenceSubjects = []; + + foreach ($inventoryItems as $inventoryItem) { + if (! $inventoryItem instanceof InventoryItem) { + continue; + } + + $policyType = trim((string) $inventoryItem->policy_type); + $subjectKey = BaselineSubjectKey::forPolicy( + $policyType, + is_string($inventoryItem->display_name ?? null) ? (string) $inventoryItem->display_name : null, + is_string($inventoryItem->external_id ?? null) ? (string) $inventoryItem->external_id : null, + ); + + $subjectRecord = $this->inventorySubjectRecord($tenant, $inventoryItem, $policyType, $subjectKey); + + if ($subjectKey === null) { + $previewSubjects[] = [ + ...$subjectRecord, + 'resolution' => 'identifier_missing', + 'duplicateCount' => 1, + ]; + + continue; + } + + $indexKey = $this->subjectIndexKey($policyType, $subjectKey); + + if (! array_key_exists($indexKey, $subjects)) { + $subjects[$indexKey] = [ + 'policyType' => $policyType, + 'subjectKey' => $subjectKey, + 'displayName' => $subjectRecord['displayName'], + 'items' => [], + ]; + } + + $subjects[$indexKey]['items'][] = $subjectRecord; + } + + foreach ($subjects as $indexKey => $subjectGroup) { + $items = is_array($subjectGroup['items'] ?? null) ? $subjectGroup['items'] : []; + $firstItem = $items[0] ?? null; + + if (! is_array($firstItem)) { + continue; + } + + $previewSubjects[] = [ + ...$firstItem, + 'resolution' => count($items) > 1 ? 'ambiguous_match' : 'resolved', + 'duplicateCount' => count($items), + ]; + + if (count($items) === 1) { + $evidenceSubjects[] = [ + 'policy_type' => (string) $firstItem['policyType'], + 'subject_external_id' => (string) $firstItem['subjectExternalId'], + ]; + } + + $subjects[$indexKey]['representative'] = $firstItem; + $subjects[$indexKey]['duplicateCount'] = count($items); + } + + return [ + 'preview_subjects' => $previewSubjects, + 'evidence_subjects' => $evidenceSubjects, + 'subjects' => $subjects, + ]; + } + + /** + * @param array> $targetIndex + * @param array $sourceEvidence + * @param array $targetEvidence + * @return array + */ + private function buildPreviewSubject( + array $sourceSubject, + Tenant $sourceTenant, + Tenant $targetTenant, + array $targetIndex, + array $sourceEvidence, + array $targetEvidence, + ): array { + $policyType = (string) ($sourceSubject['policyType'] ?? ''); + $displayName = (string) ($sourceSubject['displayName'] ?? ''); + $subjectKey = is_string($sourceSubject['subjectKey'] ?? null) ? (string) $sourceSubject['subjectKey'] : null; + $reasonCodes = []; + $state = 'blocked'; + $trustLevel = 'unusable'; + + $sourceEvidenceRecord = $this->resolvedEvidenceForSubject($sourceEvidence, $sourceSubject); + $targetEvidenceRecord = null; + $targetSubject = null; + + if (($sourceSubject['resolution'] ?? null) === 'identifier_missing') { + $reasonCodes[] = 'source_identifier_missing'; + } elseif (($sourceSubject['resolution'] ?? null) === 'ambiguous_match') { + $state = 'ambiguous'; + $trustLevel = 'diagnostic_only'; + $reasonCodes[] = 'source_subject_ambiguous'; + } elseif ($subjectKey !== null) { + $targetSubject = $targetIndex[$this->subjectIndexKey($policyType, $subjectKey)] ?? null; + + if (! is_array($targetSubject)) { + $state = 'missing'; + $trustLevel = $sourceEvidenceRecord instanceof ResolvedEvidence + && $sourceEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent + ? 'trustworthy' + : 'limited_confidence'; + $reasonCodes[] = 'target_subject_missing'; + } elseif ((int) ($targetSubject['duplicateCount'] ?? 0) > 1) { + $state = 'ambiguous'; + $trustLevel = 'diagnostic_only'; + $reasonCodes[] = 'target_subject_ambiguous'; + } else { + $representative = $targetSubject['representative'] ?? null; + + if (is_array($representative)) { + $targetEvidenceRecord = $this->resolvedEvidenceForSubject($targetEvidence, $representative); + } + + if (! $sourceEvidenceRecord instanceof ResolvedEvidence) { + $reasonCodes[] = 'source_evidence_refresh_required'; + } + + if (! $targetEvidenceRecord instanceof ResolvedEvidence) { + $reasonCodes[] = 'target_evidence_refresh_required'; + } + + if ($sourceEvidenceRecord instanceof ResolvedEvidence && $targetEvidenceRecord instanceof ResolvedEvidence) { + $state = $sourceEvidenceRecord->hash === $targetEvidenceRecord->hash ? 'match' : 'different'; + $trustLevel = $sourceEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent + && $targetEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent + ? 'trustworthy' + : 'limited_confidence'; + + if ($sourceEvidenceRecord->fidelity !== EvidenceProvenance::FidelityContent) { + $reasonCodes[] = 'source_evidence_refresh_required'; + } + + if ($targetEvidenceRecord->fidelity !== EvidenceProvenance::FidelityContent) { + $reasonCodes[] = 'target_evidence_refresh_required'; + } + } else { + $state = 'blocked'; + $trustLevel = 'unusable'; + } + } + } + + if ($state === 'missing' && ! $sourceEvidenceRecord instanceof ResolvedEvidence) { + $reasonCodes[] = 'source_evidence_refresh_required'; + } + + if ($state === 'blocked' && $reasonCodes === []) { + $reasonCodes[] = 'source_evidence_refresh_required'; + } + + $reasonCodes = array_values(array_unique($reasonCodes)); + + return [ + 'policyType' => $policyType, + 'displayName' => $displayName, + 'subjectKey' => $subjectKey, + 'state' => $state, + 'trustLevel' => $trustLevel, + 'reasonCodes' => $reasonCodes, + 'source' => $this->subjectSidePayload($sourceTenant, $sourceSubject, $sourceEvidenceRecord), + 'target' => $this->subjectSidePayload( + $targetTenant, + is_array($targetSubject['representative'] ?? null) ? $targetSubject['representative'] : null, + $targetEvidenceRecord, + ), + ]; + } + + /** + * @param list $subjects + * @return array + */ + private function resolvedEvidenceMap(Tenant $tenant, array $subjects): array + { + return $this->currentStateHashResolver->resolveForSubjects($tenant, $subjects); + } + + /** + * @param array|null $subject + * @return array + */ + private function subjectSidePayload(Tenant $tenant, ?array $subject, ?ResolvedEvidence $evidence): array + { + return [ + 'tenantId' => (int) $tenant->getKey(), + 'tenantName' => (string) $tenant->name, + 'inventoryItemId' => is_numeric($subject['inventoryItemId'] ?? null) ? (int) $subject['inventoryItemId'] : null, + 'subjectExternalId' => is_string($subject['subjectExternalId'] ?? null) ? (string) $subject['subjectExternalId'] : null, + 'displayName' => is_string($subject['displayName'] ?? null) ? (string) $subject['displayName'] : null, + 'subjectKey' => is_string($subject['subjectKey'] ?? null) ? (string) $subject['subjectKey'] : null, + 'lastSeenAt' => is_string($subject['lastSeenAt'] ?? null) ? (string) $subject['lastSeenAt'] : null, + 'evidence' => $this->evidencePayload($evidence), + ]; + } + + /** + * @return array{ + * policyType: string, + * displayName: string, + * subjectKey: ?string, + * inventoryItemId: int, + * subjectExternalId: string, + * lastSeenAt: ?string + * } + */ + private function inventorySubjectRecord(Tenant $tenant, InventoryItem $inventoryItem, string $policyType, ?string $subjectKey): array + { + $displayName = is_string($inventoryItem->display_name ?? null) ? trim((string) $inventoryItem->display_name) : ''; + $displayName = $displayName !== '' + ? $displayName + : ($subjectKey !== null ? Str::headline($subjectKey) : Str::headline($policyType)); + + return [ + 'tenantId' => (int) $tenant->getKey(), + 'policyType' => $policyType, + 'displayName' => $displayName, + 'subjectKey' => $subjectKey, + 'inventoryItemId' => (int) $inventoryItem->getKey(), + 'subjectExternalId' => (string) $inventoryItem->external_id, + 'lastSeenAt' => $inventoryItem->last_seen_at?->toIso8601String(), + ]; + } + + /** + * @param array $evidenceMap + */ + private function resolvedEvidenceForSubject(array $evidenceMap, array $subject): ?ResolvedEvidence + { + $policyType = trim((string) ($subject['policyType'] ?? '')); + $subjectExternalId = trim((string) ($subject['subjectExternalId'] ?? '')); + + if ($policyType === '' || $subjectExternalId === '') { + return null; + } + + $key = $policyType.'|'.$subjectExternalId; + $evidence = $evidenceMap[$key] ?? null; + + return $evidence instanceof ResolvedEvidence ? $evidence : null; + } + + /** + * @return array{ + * hash: string, + * fidelity: string, + * source: string, + * observedAt: ?string, + * policyVersionId: ?int, + * operationRunId: ?int, + * capturePurpose: ?string + * }|null + */ + private function evidencePayload(?ResolvedEvidence $evidence): ?array + { + if (! $evidence instanceof ResolvedEvidence) { + return null; + } + + return [ + 'hash' => $evidence->hash, + 'fidelity' => $evidence->fidelity, + 'source' => $evidence->source, + 'observedAt' => $evidence->observedAt?->toIso8601String(), + 'policyVersionId' => is_numeric($evidence->meta['policy_version_id'] ?? null) + ? (int) $evidence->meta['policy_version_id'] + : null, + 'operationRunId' => is_numeric($evidence->meta['operation_run_id'] ?? null) + ? (int) $evidence->meta['operation_run_id'] + : null, + 'capturePurpose' => is_string($evidence->meta['capture_purpose'] ?? null) + ? (string) $evidence->meta['capture_purpose'] + : null, + ]; + } + + private function subjectIndexKey(string $policyType, string $subjectKey): string + { + return $policyType.'|'.$subjectKey; + } +} diff --git a/apps/platform/app/Support/PortfolioCompare/CrossTenantCompareSelection.php b/apps/platform/app/Support/PortfolioCompare/CrossTenantCompareSelection.php new file mode 100644 index 00000000..46a59b5a --- /dev/null +++ b/apps/platform/app/Support/PortfolioCompare/CrossTenantCompareSelection.php @@ -0,0 +1,83 @@ + + */ + public array $policyTypes; + + /** + * @param list $policyTypes + */ + public function __construct( + Tenant $sourceTenant, + Tenant $targetTenant, + array $policyTypes = [], + ) { + $this->sourceTenant = $sourceTenant; + $this->targetTenant = $targetTenant; + + if ((int) $this->sourceTenant->getKey() === (int) $this->targetTenant->getKey()) { + throw new InvalidArgumentException('Source and target tenants must differ.'); + } + + if ((int) $this->sourceTenant->workspace_id !== (int) $this->targetTenant->workspace_id) { + throw new InvalidArgumentException('Source and target tenants must belong to the same workspace.'); + } + + $this->policyTypes = $this->normalizePolicyTypes($policyTypes); + } + + public function workspaceId(): int + { + return (int) $this->sourceTenant->workspace_id; + } + + public function sourceTenantId(): int + { + return (int) $this->sourceTenant->getKey(); + } + + public function targetTenantId(): int + { + return (int) $this->targetTenant->getKey(); + } + + public function hasPolicyTypeFilter(): bool + { + return $this->policyTypes !== []; + } + + /** + * @param list $policyTypes + * @return list + */ + private function normalizePolicyTypes(array $policyTypes): array + { + $normalized = array_values(array_unique(array_filter(array_map(static function (mixed $policyType): ?string { + if (! is_string($policyType)) { + return null; + } + + $normalizedPolicyType = trim($policyType); + + return $normalizedPolicyType !== '' ? $normalizedPolicyType : null; + }, $policyTypes)))); + + sort($normalized, SORT_STRING); + + return $normalized; + } +} diff --git a/apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionPreflight.php b/apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionPreflight.php new file mode 100644 index 00000000..c1c5c466 --- /dev/null +++ b/apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionPreflight.php @@ -0,0 +1,143 @@ +, + * subjects?: list> + * } $preview + * @return array{ + * selection: array, + * summary: array{ready: int, blocked: int, manual_mapping_required: int, total: int}, + * blockedReasonCounts: array, + * buckets: array{ + * ready: list>, + * blocked: list>, + * manual_mapping_required: list> + * } + * } + */ + public function build(array $preview): array + { + $subjects = is_array($preview['subjects'] ?? null) ? $preview['subjects'] : []; + $buckets = [ + 'ready' => [], + 'blocked' => [], + 'manual_mapping_required' => [], + ]; + $blockedReasonCounts = []; + + foreach ($subjects as $subject) { + if (! is_array($subject)) { + continue; + } + + $decision = $this->classifySubject($subject); + $subject['preflight'] = $decision; + $buckets[$decision['bucket']][] = $subject; + + if ($decision['bucket'] !== 'ready') { + foreach ($decision['reasonCodes'] as $reasonCode) { + $blockedReasonCounts[$reasonCode] = ($blockedReasonCounts[$reasonCode] ?? 0) + 1; + } + } + } + + return [ + 'selection' => is_array($preview['selection'] ?? null) ? $preview['selection'] : [], + 'summary' => [ + 'ready' => count($buckets['ready']), + 'blocked' => count($buckets['blocked']), + 'manual_mapping_required' => count($buckets['manual_mapping_required']), + 'total' => count($buckets['ready']) + count($buckets['blocked']) + count($buckets['manual_mapping_required']), + ], + 'blockedReasonCounts' => $blockedReasonCounts, + 'buckets' => $buckets, + ]; + } + + /** + * @param array $subject + * @return array{bucket: 'ready'|'blocked'|'manual_mapping_required', reasonCodes: list, reasonLabels: list} + */ + private function classifySubject(array $subject): array + { + $state = is_string($subject['state'] ?? null) ? (string) $subject['state'] : 'blocked'; + $reasonCodes = is_array($subject['reasonCodes'] ?? null) + ? array_values(array_filter($subject['reasonCodes'], 'is_string')) + : []; + + if (in_array('source_identifier_missing', $reasonCodes, true)) { + return $this->decision('blocked', ['source_identifier_missing']); + } + + if (in_array('source_subject_ambiguous', $reasonCodes, true)) { + return $this->decision('blocked', ['source_subject_ambiguous']); + } + + if (in_array('target_subject_ambiguous', $reasonCodes, true) || $state === 'ambiguous') { + return $this->decision('manual_mapping_required', ['target_subject_ambiguous']); + } + + $sourceEvidence = is_array(data_get($subject, 'source.evidence')) ? data_get($subject, 'source.evidence') : null; + $targetEvidence = is_array(data_get($subject, 'target.evidence')) ? data_get($subject, 'target.evidence') : null; + + if (! $this->evidenceSupportsPromotion($sourceEvidence)) { + return $this->decision('blocked', ['source_evidence_refresh_required']); + } + + if ($state !== 'missing' && ! $this->evidenceSupportsPromotion($targetEvidence)) { + return $this->decision('blocked', ['target_evidence_refresh_required']); + } + + return match ($state) { + 'match' => $this->decision('ready', ['target_already_aligned']), + 'different' => $this->decision('ready', ['target_subject_requires_update']), + 'missing' => $this->decision('ready', ['target_subject_missing']), + default => $this->decision('blocked', ['source_evidence_refresh_required']), + }; + } + + /** + * @param array|null $evidence + */ + private function evidenceSupportsPromotion(?array $evidence): bool + { + return is_array($evidence) + && is_string($evidence['fidelity'] ?? null) + && (string) $evidence['fidelity'] === 'content'; + } + + /** + * @param list $reasonCodes + * @return array{bucket: 'ready'|'blocked'|'manual_mapping_required', reasonCodes: list, reasonLabels: list} + */ + private function decision(string $bucket, array $reasonCodes): array + { + return [ + 'bucket' => $bucket, + 'reasonCodes' => $reasonCodes, + 'reasonLabels' => array_map(fn (string $reasonCode): string => $this->reasonLabel($reasonCode), $reasonCodes), + ]; + } + + private function reasonLabel(string $reasonCode): string + { + return match ($reasonCode) { + 'source_identifier_missing' => 'Source tenant subject is missing a stable compare identifier.', + 'source_subject_ambiguous' => 'Source tenant subject resolves to multiple candidates and cannot drive promotion safely.', + 'target_subject_ambiguous' => 'Target tenant has multiple matching subjects and needs manual mapping.', + 'source_evidence_refresh_required' => 'Refresh source evidence before relying on this subject.', + 'target_evidence_refresh_required' => 'Refresh target evidence before relying on this subject.', + 'target_already_aligned' => 'Target tenant already matches the source for this subject.', + 'target_subject_requires_update' => 'Target tenant differs from the source but has enough evidence for later promotion planning.', + 'target_subject_missing' => 'Target tenant does not currently contain this subject and can be planned as a missing target item.', + default => 'This subject needs additional review before promotion planning can continue.', + }; + } +} diff --git a/apps/platform/resources/views/filament/pages/cross-tenant-compare.blade.php b/apps/platform/resources/views/filament/pages/cross-tenant-compare.blade.php new file mode 100644 index 00000000..bfe60b0e --- /dev/null +++ b/apps/platform/resources/views/filament/pages/cross-tenant-compare.blade.php @@ -0,0 +1,208 @@ + + @php + $preview = is_array($preview ?? null) ? $preview : null; + $preflight = is_array($preflight ?? null) ? $preflight : null; + $previewSummary = is_array($preview['summary'] ?? null) ? $preview['summary'] : []; + $preflightSummary = is_array($preflight['summary'] ?? null) ? $preflight['summary'] : []; + $blockedReasonCounts = is_array($preflight['blockedReasonCounts'] ?? null) ? $preflight['blockedReasonCounts'] : []; + $sourceTenantName = data_get($preview, 'selection.sourceTenantName'); + $targetTenantName = data_get($preview, 'selection.targetTenantName'); + $selectedPolicyTypes = data_get($preview, 'selection.policyTypes', []); + @endphp + + + + Compare one authorized source tenant to one authorized target tenant from a canonical workspace surface. Preview stays read only and promotion remains preflight only. + + +
      +
      +
      + {{ $this->form }} + +
      +
      +
      Shareable compare scope
      +

      + Source tenant, target tenant, and governed-subject filters live on the URL so the same compare preview can be reopened or shared. +

      +
      + +
      + + Run compare preview + + + @if ($this->hasActiveSelection()) + + Clear selection + + @endif +
      +
      +
      +
      + + @if (filled($selectionMessage)) +
      + {{ $selectionMessage }} +
      + @endif + + @if ($preview === null) +
      + Choose a source tenant and a target tenant to build a compare preview. The source and target must be different tenants inside the active workspace. +
      + @endif +
      +
      + + @if ($preview !== null) + + + Decision-first summary of governed subjects. Raw payloads stay on the existing tenant and baseline surfaces. + + +
      +
      +
      +
      + Source tenant: {{ $sourceTenantName }} + Target tenant: {{ $targetTenantName }} + @foreach ($selectedPolicyTypes as $policyType) + {{ $this->stateLabel($policyType) }} + @endforeach +
      + +
      + @if (filled($this->sourceTenantUrl())) + + Open source tenant + + @endif + + @if (filled($this->targetTenantUrl())) + + Open target tenant + + @endif +
      + +

      + The preview groups governed subjects into reproducible compare states so you can decide whether the target is aligned, missing, blocked, or needs manual review. +

      +
      + +
      + @foreach (['match', 'different', 'missing', 'ambiguous', 'blocked', 'total'] as $state) +
      +
      {{ $this->stateLabel($state) }}
      +
      {{ (int) ($previewSummary[$state] ?? 0) }}
      +
      + @endforeach +
      +
      + +
      +
      +
      Governed subject
      +
      Reasoning
      +
      Compare state
      +
      + +
      + @foreach (data_get($preview, 'subjects', []) as $subject) +
      +
      +
      {{ data_get($subject, 'displayName') }}
      +
      + {{ $this->stateLabel((string) data_get($subject, 'policyType', 'unknown')) }} + @if (filled(data_get($subject, 'subjectKey'))) + {{ data_get($subject, 'subjectKey') }} + @endif +
      +
      + +
      + @forelse (data_get($subject, 'reasonCodes', []) as $reasonCode) + {{ $this->reasonLabel((string) $reasonCode) }} + @empty + No blocking reason. + @endforelse +
      + +
      + + {{ $this->stateLabel((string) data_get($subject, 'state', 'unknown')) }} + +
      +
      + @endforeach +
      +
      +
      +
      + @endif + + @if ($preflight !== null) + + + Read-only readiness view. No target mutation, queue dispatch, or operation run is created in this slice. + + +
      +
      + @foreach (['ready', 'blocked', 'manual_mapping_required', 'total'] as $state) +
      +
      {{ $this->stateLabel($state) }}
      +
      {{ (int) ($preflightSummary[$state] ?? 0) }}
      +
      + @endforeach +
      + + @if ($blockedReasonCounts !== []) +
      +
      Top blocked reasons
      +
      + @foreach ($blockedReasonCounts as $reasonCode => $count) + {{ $this->reasonLabel((string) $reasonCode) }}: {{ (int) $count }} + @endforeach +
      +
      + @endif + +
      + @foreach (['ready', 'manual_mapping_required', 'blocked'] as $bucket) +
      +
      +
      {{ $this->stateLabel($bucket) }}
      + + {{ count(data_get($preflight, 'buckets.'.$bucket, [])) }} + +
      + +
      + @forelse (data_get($preflight, 'buckets.'.$bucket, []) as $subject) +
      +
      {{ data_get($subject, 'displayName') }}
      + @if (data_get($subject, 'preflight.reasonLabels', []) !== []) +
      + @foreach (data_get($subject, 'preflight.reasonLabels', []) as $reasonLabel) + {{ $reasonLabel }} + @endforeach +
      + @endif +
      + @empty +
      No governed subjects in this bucket.
      + @endforelse +
      +
      + @endforeach +
      +
      +
      + @endif + + +
      \ No newline at end of file diff --git a/apps/platform/tests/Feature/Concerns/BuildsPortfolioCompareFixtures.php b/apps/platform/tests/Feature/Concerns/BuildsPortfolioCompareFixtures.php new file mode 100644 index 00000000..1945c41f --- /dev/null +++ b/apps/platform/tests/Feature/Concerns/BuildsPortfolioCompareFixtures.php @@ -0,0 +1,119 @@ +create([ + 'name' => 'Source Tenant', + ]); + + [$user, $sourceTenant] = createUserWithTenant( + tenant: $sourceTenant, + role: $tenantRole, + workspaceRole: $workspaceRole, + ); + + $workspace = Workspace::query()->findOrFail((int) $sourceTenant->workspace_id); + + $targetTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Target Tenant', + ]); + + $user->tenants()->syncWithoutDetaching([ + (int) $targetTenant->getKey() => ['role' => $tenantRole], + ]); + + app(CapabilityResolver::class)->clearCache(); + app(WorkspaceCapabilityResolver::class)->clearCache(); + + return [ + 'user' => $user, + 'workspace' => $workspace, + 'sourceTenant' => $sourceTenant, + 'targetTenant' => $targetTenant, + ]; + } + + /** + * @return array{0: string, 1: array} + */ + protected function setAdminWorkspaceContext(User $user, Workspace $workspace): array + { + $this->actingAs($user); + + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + return [WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]; + } + + /** + * @param array $snapshot + * @return array{policy: Policy, version: PolicyVersion, inventory: InventoryItem} + */ + protected function createPortfolioCompareSubject( + Tenant $tenant, + string $displayName, + array $snapshot, + string $policyType = 'deviceConfiguration', + ?string $externalId = null, + ): array { + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => $policyType, + 'external_id' => $externalId ?? str($displayName)->slug()->append('-')->append((string) str()->uuid())->toString(), + 'display_name' => $displayName, + 'platform' => 'windows', + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_type' => $policyType, + 'platform' => 'windows', + 'captured_at' => now(), + 'snapshot' => $snapshot, + 'assignments' => [], + 'scope_tags' => [], + ]); + + $inventory = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => $policyType, + 'external_id' => (string) $policy->external_id, + 'display_name' => $displayName, + 'last_seen_at' => now(), + ]); + + return [ + 'policy' => $policy, + 'version' => $version, + 'inventory' => $inventory, + ]; + } +} \ No newline at end of file diff --git a/apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php new file mode 100644 index 00000000..b9847d95 --- /dev/null +++ b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php @@ -0,0 +1,97 @@ +create(); + $user = User::factory()->create(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(CrossTenantComparePage::getUrl(panel: 'admin')) + ->assertNotFound(); +}); + +it('returns 403 for workspace members missing baseline view capability on the compare route', 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', + ]); + + $resolver = \Mockery::mock(WorkspaceCapabilityResolver::class); + $resolver->shouldReceive('isMember')->andReturnTrue(); + $resolver->shouldReceive('can')->andReturnFalse(); + app()->instance(WorkspaceCapabilityResolver::class, $resolver); + + $this->actingAs($viewer) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(CrossTenantComparePage::getUrl(panel: 'admin')) + ->assertForbidden(); +}); + +it('returns 404 when the requested target tenant is outside the actor scope', function (): void { + $fixture = $this->makeCrossTenantCompareFixture(); + $hiddenTarget = Tenant::factory()->create([ + 'workspace_id' => (int) $fixture['workspace']->getKey(), + 'name' => 'Hidden Target', + ]); + + $session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + $this->withSession($session) + ->get(CrossTenantComparePage::getUrl(parameters: [ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $hiddenTarget->getKey(), + ], panel: 'admin')) + ->assertNotFound(); +}); + +it('keeps promotion preflight visible but disabled for readonly members and forbids forced execution', function (): void { + $fixture = $this->makeCrossTenantCompareFixture(workspaceRole: 'readonly', tenantRole: 'readonly'); + + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Readonly Policy', + snapshot: ['settings' => [['key' => 'readonly', 'value' => 1]]], + ); + $this->createPortfolioCompareSubject( + tenant: $fixture['targetTenant'], + displayName: 'Readonly Policy', + snapshot: ['settings' => [['key' => 'readonly', 'value' => 1]]], + ); + + $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + $query = [ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ]; + + Livewire::withQueryParams($query) + ->actingAs($fixture['user']) + ->test(CrossTenantComparePage::class) + ->assertActionVisible('generatePromotionPreflight') + ->assertActionDisabled('generatePromotionPreflight') + ->assertActionExists('generatePromotionPreflight', fn (Action $action): bool => $action->getTooltip() === 'You need workspace baseline manage access to generate a promotion preflight.') + ->call('generatePromotionPreflight') + ->assertForbidden(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php new file mode 100644 index 00000000..f51742b5 --- /dev/null +++ b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php @@ -0,0 +1,188 @@ +makePortfolioTriageActor('Anchor Tenant'); + $targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant'); + + $backupSet = $this->seedPortfolioBackupConcern($targetTenant, TenantBackupHealthAssessment::POSTURE_STALE); + $this->seedPortfolioRecoveryConcern($targetTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, $backupSet); + + $triageState = $this->portfolioReturnFilters( + [TenantBackupHealthAssessment::POSTURE_STALE], + [TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED], + [], + TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST, + ); + + $expectedUrl = TenantResource::crossTenantCompareOpenUrl($targetTenant, $triageState); + + $this->portfolioTriageRegistryList($user, $anchorTenant, $triageState) + ->assertTableActionVisible('compareTenants', $targetTenant) + ->assertTableActionHasUrl('compareTenants', $expectedUrl, $targetTenant); + + $query = crossTenantCompareLaunchQuery($expectedUrl); + $backUrl = urldecode((string) data_get($query, 'nav.back_url')); + + expect($query)->toMatchArray([ + 'target_tenant_id' => (string) $targetTenant->getKey(), + ]) + ->and(data_get($query, 'nav.source_surface'))->toBe('tenant_registry') + ->and(data_get($query, 'nav.tenant_id'))->toBe((string) $targetTenant->getKey()) + ->and(data_get($query, 'nav.back_label'))->toBe('Back to tenant registry') + ->and($backUrl)->toContain('backup_posture[0]='.TenantBackupHealthAssessment::POSTURE_STALE) + ->and($backUrl)->toContain('recovery_evidence[0]='.TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED) + ->and($backUrl)->toContain('triage_sort='.TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST); + + Livewire::withQueryParams($query) + ->actingAs($user) + ->test(CrossTenantComparePage::class) + ->assertSet('sourceTenantId', null) + ->assertSet('targetTenantId', (string) $targetTenant->getKey()) + ->assertActionVisible('return_to_origin') + ->assertActionExists('return_to_origin', fn (Action $action): bool => $action->getLabel() === 'Back to tenant registry' + && $action->getUrl() === TenantResource::getUrl(panel: 'admin', parameters: $triageState)); +}); + +it('launches cross-tenant compare from an exact-two bulk selection with both tenants prefilled', function (): void { + [$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant'); + $targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant'); + + $anchorBackupSet = $this->seedPortfolioBackupConcern($anchorTenant, TenantBackupHealthAssessment::POSTURE_STALE); + $this->seedPortfolioRecoveryConcern($anchorTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, $anchorBackupSet); + + $backupSet = $this->seedPortfolioBackupConcern($targetTenant, TenantBackupHealthAssessment::POSTURE_STALE); + $this->seedPortfolioRecoveryConcern($targetTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, $backupSet); + + $triageState = $this->portfolioReturnFilters( + [TenantBackupHealthAssessment::POSTURE_STALE], + [TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED], + [], + TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST, + ); + + $expectedUrl = TenantResource::crossTenantCompareOpenUrlForSelection( + targetTenant: $targetTenant, + triageState: $triageState, + sourceTenant: $anchorTenant, + ); + + $this->portfolioTriageRegistryList($user, $anchorTenant, $triageState) + ->selectTableRecords([$anchorTenant, $targetTenant]) + ->assertTableBulkActionVisible('compareSelected') + ->callTableBulkAction('compareSelected', [$anchorTenant, $targetTenant]) + ->assertRedirect($expectedUrl); + + $query = crossTenantCompareLaunchQuery($expectedUrl); + $backUrl = urldecode((string) data_get($query, 'nav.back_url')); + + expect($query)->toMatchArray([ + 'source_tenant_id' => (string) $anchorTenant->getKey(), + 'target_tenant_id' => (string) $targetTenant->getKey(), + ]) + ->and(data_get($query, 'nav.source_surface'))->toBe('tenant_registry') + ->and(data_get($query, 'nav.back_label'))->toBe('Back to tenant registry') + ->and(data_get($query, 'nav.tenant_id'))->toBeNull() + ->and($backUrl)->toContain('backup_posture[0]='.TenantBackupHealthAssessment::POSTURE_STALE) + ->and($backUrl)->toContain('recovery_evidence[0]='.TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED) + ->and($backUrl)->toContain('triage_sort='.TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST); + + Livewire::withQueryParams($query) + ->actingAs($user) + ->test(CrossTenantComparePage::class) + ->assertSet('sourceTenantId', (string) $anchorTenant->getKey()) + ->assertSet('targetTenantId', (string) $targetTenant->getKey()) + ->assertActionVisible('return_to_origin'); +}); + +it('rejects the bulk compare action until exactly two active tenants are selected', function (): void { + [$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant'); + $targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant'); + $thirdTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Third Tenant'); + + $this->portfolioTriageRegistryList($user, $anchorTenant) + ->selectTableRecords([$anchorTenant]) + ->assertTableBulkActionVisible('compareSelected') + ->callTableBulkAction('compareSelected', [$anchorTenant]) + ->assertNotified('Select exactly two tenants to compare.'); + + $this->portfolioTriageRegistryList($user, $anchorTenant) + ->selectTableRecords([$anchorTenant, $targetTenant, $thirdTenant]) + ->assertTableBulkActionVisible('compareSelected') + ->callTableBulkAction('compareSelected', [$anchorTenant, $targetTenant, $thirdTenant]) + ->assertNotified('Select exactly two tenants to compare.'); +}); + +it('rejects the bulk compare action when a selected tenant is not active', function (): void { + [$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant'); + $onboardingTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Onboarding Tenant'); + + $onboardingTenant->forceFill([ + 'status' => Tenant::STATUS_ONBOARDING, + ])->save(); + + $this->portfolioTriageRegistryList($user, $anchorTenant) + ->selectTableRecords([$anchorTenant, $onboardingTenant]) + ->assertTableBulkActionVisible('compareSelected') + ->callTableBulkAction('compareSelected', [$anchorTenant, $onboardingTenant]) + ->assertNotified('Only active tenants can be compared.'); +}); + +it('hides the compare launch action when workspace baseline view capability is missing', function (): void { + [$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant'); + $targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant'); + + $resolver = \Mockery::mock(WorkspaceCapabilityResolver::class); + $resolver->shouldReceive('isMember')->andReturnTrue(); + $resolver->shouldReceive('can')->andReturnFalse(); + app()->instance(WorkspaceCapabilityResolver::class, $resolver); + + $this->portfolioTriageRegistryList($user, $anchorTenant) + ->assertTableActionHidden('compareTenants', $targetTenant); +}); + +it('hides the compare launch action when the actor lacks tenant view on the launched tenant', function (): void { + [$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant'); + $targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant'); + + $resolver = \Mockery::mock(CapabilityResolver::class); + $resolver->shouldReceive('primeMemberships')->andReturnNull(); + $resolver->shouldReceive('isMember')->andReturnTrue(); + $resolver->shouldReceive('can')->andReturnUsing(function (mixed $actor, mixed $tenant, string $capability) use ($targetTenant): bool { + if ($tenant instanceof Tenant + && (int) $tenant->getKey() === (int) $targetTenant->getKey() + && $capability === Capabilities::TENANT_VIEW) { + return false; + } + + return true; + }); + app()->instance(CapabilityResolver::class, $resolver); + + $this->portfolioTriageRegistryList($user, $anchorTenant) + ->assertTableActionHidden('compareTenants', $targetTenant); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php new file mode 100644 index 00000000..ddd5b709 --- /dev/null +++ b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php @@ -0,0 +1,82 @@ +makeCrossTenantCompareFixture(); + + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'WiFi Corp', + snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]], + ); + $this->createPortfolioCompareSubject( + tenant: $fixture['targetTenant'], + displayName: 'WiFi Corp', + snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]], + ); + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Windows Compliance', + snapshot: ['settings' => [['key' => 'compliance', 'value' => 1]]], + ); + $this->createPortfolioCompareSubject( + tenant: $fixture['targetTenant'], + displayName: 'Windows Compliance', + snapshot: ['settings' => [['key' => 'compliance', 'value' => 2]]], + ); + + $session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + $query = [ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ]; + + $this->withSession($session) + ->get(CrossTenantComparePage::getUrl(parameters: $query, panel: 'admin')) + ->assertOk() + ->assertSee('Cross-tenant compare') + ->assertSee('Compare preview') + ->assertSee('WiFi Corp') + ->assertSee('Windows Compliance') + ->assertSee('Source tenant: '.$fixture['sourceTenant']->name) + ->assertSee('Target tenant: '.$fixture['targetTenant']->name) + ->assertSee(TenantResource::getUrl('view', ['record' => $fixture['sourceTenant']], panel: 'admin'), false) + ->assertSee(TenantResource::getUrl('view', ['record' => $fixture['targetTenant']], panel: 'admin'), false); + + Livewire::withQueryParams($query) + ->actingAs($fixture['user']) + ->test(CrossTenantComparePage::class) + ->assertActionVisible('generatePromotionPreflight') + ->assertActionEnabled('generatePromotionPreflight') + ->call('generatePromotionPreflight') + ->assertHasNoErrors() + ->assertSee('Promotion preflight') + ->assertSee('WiFi Corp') + ->assertSee('Windows Compliance'); +}); + +it('rejects the same tenant as source and target without rendering compare results', function (): void { + $fixture = $this->makeCrossTenantCompareFixture(); + + $session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + $this->withSession($session) + ->get(CrossTenantComparePage::getUrl(parameters: [ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + ], panel: 'admin')) + ->assertOk() + ->assertSee('Choose two different tenants.') + ->assertDontSee('data-testid="cross-tenant-compare-preview"', false) + ->assertDontSee('Promotion preflight'); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php new file mode 100644 index 00000000..87b85cb3 --- /dev/null +++ b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php @@ -0,0 +1,62 @@ +makeCrossTenantCompareFixture(); + + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Audit Policy', + snapshot: ['settings' => [['key' => 'audit', 'value' => 1]]], + ); + $this->createPortfolioCompareSubject( + tenant: $fixture['targetTenant'], + displayName: 'Audit Policy', + snapshot: ['settings' => [['key' => 'audit', 'value' => 2]]], + ); + + $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + $operationRunCount = OperationRun::query()->count(); + $policyVersionCount = PolicyVersion::query()->count(); + + Livewire::withQueryParams([ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ]) + ->actingAs($fixture['user']) + ->test(CrossTenantComparePage::class) + ->call('generatePromotionPreflight') + ->assertHasNoErrors(); + + $audit = AuditLog::query() + ->where('workspace_id', (int) $fixture['workspace']->getKey()) + ->where('action', AuditActionId::CrossTenantPromotionPreflightGenerated->value) + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull() + ->and($audit?->status)->toBe('success') + ->and($audit?->resource_type)->toBe('cross_tenant_promotion_preflight') + ->and(data_get($audit?->metadata, 'source_tenant_id'))->toBe((int) $fixture['sourceTenant']->getKey()) + ->and(data_get($audit?->metadata, 'target_tenant_id'))->toBe((int) $fixture['targetTenant']->getKey()) + ->and(data_get($audit?->metadata, 'ready_count'))->toBe(1) + ->and(data_get($audit?->metadata, 'blocked_count'))->toBe(0) + ->and(data_get($audit?->metadata, 'manual_mapping_required_count'))->toBe(0); + + expect(OperationRun::query()->count())->toBe($operationRunCount) + ->and(PolicyVersion::query()->count())->toBe($policyVersionCount); +}); \ No newline at end of file diff --git a/apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php b/apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php new file mode 100644 index 00000000..e764eee0 --- /dev/null +++ b/apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php @@ -0,0 +1,192 @@ + [['key' => 'wifi', 'value' => 1]]], + ); + createComparedPolicy( + tenant: $fixture['targetTenant'], + displayName: 'WiFi Corp', + snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]], + ); + + createComparedPolicy( + tenant: $fixture['sourceTenant'], + displayName: 'Windows Compliance', + snapshot: ['settings' => [['key' => 'compliance', 'value' => 1]]], + ); + createComparedPolicy( + tenant: $fixture['targetTenant'], + displayName: 'Windows Compliance', + snapshot: ['settings' => [['key' => 'compliance', 'value' => 2]]], + ); + + createComparedPolicy( + tenant: $fixture['sourceTenant'], + displayName: 'VPN Profile', + snapshot: ['settings' => [['key' => 'vpn', 'value' => 1]]], + ); + + $selection = new CrossTenantCompareSelection( + sourceTenant: $fixture['sourceTenant'], + targetTenant: $fixture['targetTenant'], + policyTypes: ['deviceConfiguration'], + ); + + $builder = app(CrossTenantComparePreviewBuilder::class); + $preview = $builder->build($selection); + + expect($preview['summary'])->toBe([ + 'match' => 1, + 'different' => 1, + 'missing' => 1, + 'ambiguous' => 0, + 'blocked' => 0, + 'total' => 3, + ]); + + $subjects = collect($preview['subjects'])->keyBy('displayName'); + + expect($subjects->get('WiFi Corp'))->not->toBeNull() + ->and($subjects->get('WiFi Corp')['state'])->toBe('match') + ->and(data_get($subjects->get('WiFi Corp'), 'source.evidence.fidelity'))->toBe('content') + ->and(data_get($subjects->get('WiFi Corp'), 'target.evidence.fidelity'))->toBe('content') + ->and($subjects->get('Windows Compliance')['state'])->toBe('different') + ->and($subjects->get('VPN Profile')['state'])->toBe('missing'); + + expect($builder->build($selection))->toBe($preview); +}); + +it('marks unresolved source identity and duplicate target matches distinctly', function (): void { + $fixture = crossTenantCompareFixture(); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'policy_type' => 'deviceConfiguration', + 'display_name' => ' ', + 'external_id' => 'source-without-identifier', + ]); + + createComparedPolicy( + tenant: $fixture['sourceTenant'], + displayName: 'Duplicated Policy', + snapshot: ['settings' => [['key' => 'dup', 'value' => 1]]], + ); + + createComparedPolicy( + tenant: $fixture['targetTenant'], + displayName: 'Duplicated Policy', + externalId: 'dup-target-1', + snapshot: ['settings' => [['key' => 'dup', 'value' => 1]]], + ); + createComparedPolicy( + tenant: $fixture['targetTenant'], + displayName: 'Duplicated Policy', + externalId: 'dup-target-2', + snapshot: ['settings' => [['key' => 'dup', 'value' => 1]]], + ); + + $preview = app(CrossTenantComparePreviewBuilder::class)->build(new CrossTenantCompareSelection( + sourceTenant: $fixture['sourceTenant'], + targetTenant: $fixture['targetTenant'], + policyTypes: ['deviceConfiguration'], + )); + + expect($preview['summary'])->toBe([ + 'match' => 0, + 'different' => 0, + 'missing' => 0, + 'ambiguous' => 1, + 'blocked' => 1, + 'total' => 2, + ]); + + $identifierGap = collect($preview['subjects']) + ->first(fn (array $subject): bool => in_array('source_identifier_missing', $subject['reasonCodes'], true)); + $ambiguousTarget = collect($preview['subjects']) + ->first(fn (array $subject): bool => in_array('target_subject_ambiguous', $subject['reasonCodes'], true)); + + expect($identifierGap)->toBeArray() + ->and($identifierGap['state'])->toBe('blocked') + ->and($identifierGap['trustLevel'])->toBe('unusable') + ->and($ambiguousTarget)->toBeArray() + ->and($ambiguousTarget['state'])->toBe('ambiguous') + ->and($ambiguousTarget['trustLevel'])->toBe('diagnostic_only'); +}); + +/** + * @return array{sourceTenant: Tenant, targetTenant: Tenant} + */ +function crossTenantCompareFixture(): array +{ + $sourceTenant = Tenant::factory()->create(); + $targetTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $sourceTenant->workspace_id, + ]); + + return [ + 'sourceTenant' => $sourceTenant, + 'targetTenant' => $targetTenant, + ]; +} + +/** + * @param array $snapshot + * @return array{policy: Policy, version: PolicyVersion, inventory: InventoryItem} + */ +function createComparedPolicy( + Tenant $tenant, + string $displayName, + array $snapshot, + string $policyType = 'deviceConfiguration', + ?string $externalId = null, +): array { + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => $policyType, + 'external_id' => $externalId ?? str($displayName)->slug()->append('-')->append((string) str()->uuid())->toString(), + 'display_name' => $displayName, + 'platform' => 'windows', + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_type' => $policyType, + 'platform' => 'windows', + 'captured_at' => now(), + 'snapshot' => $snapshot, + 'assignments' => [], + 'scope_tags' => [], + ]); + + $inventory = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => $policyType, + 'external_id' => (string) $policy->external_id, + 'display_name' => $displayName, + 'last_seen_at' => now(), + ]); + + return [ + 'policy' => $policy, + 'version' => $version, + 'inventory' => $inventory, + ]; +} diff --git a/apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php b/apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php new file mode 100644 index 00000000..bb19d1f2 --- /dev/null +++ b/apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php @@ -0,0 +1,227 @@ + [['key' => 'aligned', 'value' => 1]]], + ); + createPromotionSubject( + tenant: $fixture['targetTenant'], + displayName: 'Aligned Policy', + snapshot: ['settings' => [['key' => 'aligned', 'value' => 1]]], + ); + + createPromotionSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Different Policy', + snapshot: ['settings' => [['key' => 'different', 'value' => 1]]], + ); + createPromotionSubject( + tenant: $fixture['targetTenant'], + displayName: 'Different Policy', + snapshot: ['settings' => [['key' => 'different', 'value' => 2]]], + ); + + createPromotionSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Missing Policy', + snapshot: ['settings' => [['key' => 'missing', 'value' => 1]]], + ); + + createPromotionSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Manual Mapping Policy', + snapshot: ['settings' => [['key' => 'manual', 'value' => 1]]], + ); + createPromotionSubject( + tenant: $fixture['targetTenant'], + displayName: 'Manual Mapping Policy', + snapshot: ['settings' => [['key' => 'manual', 'value' => 1]]], + externalId: 'manual-target-1', + ); + createPromotionSubject( + tenant: $fixture['targetTenant'], + displayName: 'Manual Mapping Policy', + snapshot: ['settings' => [['key' => 'manual', 'value' => 1]]], + externalId: 'manual-target-2', + ); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'policy_type' => 'deviceConfiguration', + 'display_name' => ' ', + 'external_id' => 'missing-source-identifier', + ]); + + Policy::factory()->create([ + 'tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'meta-only-source', + 'display_name' => 'Refresh Required Policy', + 'platform' => 'windows', + ]); + InventoryItem::factory()->create([ + 'tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'meta-only-source', + 'display_name' => 'Refresh Required Policy', + 'meta_jsonb' => ['etag' => 'source-meta-only'], + ]); + + Policy::factory()->create([ + 'tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'meta-only-target', + 'display_name' => 'Refresh Required Policy', + 'platform' => 'windows', + ]); + InventoryItem::factory()->create([ + 'tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'meta-only-target', + 'display_name' => 'Refresh Required Policy', + 'meta_jsonb' => ['etag' => 'target-meta-only'], + ]); + + $selection = new CrossTenantCompareSelection( + sourceTenant: $fixture['sourceTenant'], + targetTenant: $fixture['targetTenant'], + policyTypes: ['deviceConfiguration'], + ); + + $preview = app(CrossTenantComparePreviewBuilder::class)->build($selection); + $preflight = app(CrossTenantPromotionPreflight::class)->build($preview); + + expect($preflight['summary'])->toBe([ + 'ready' => 3, + 'blocked' => 2, + 'manual_mapping_required' => 1, + 'total' => 6, + ]); + + $bucketByName = collect($preflight['buckets']['ready']) + ->merge($preflight['buckets']['blocked']) + ->merge($preflight['buckets']['manual_mapping_required']) + ->mapWithKeys(static fn (array $subject): array => [ + (string) ($subject['displayName'] ?? '') => $subject['preflight'], + ]); + + expect(data_get($bucketByName, 'Aligned Policy.bucket'))->toBe('ready') + ->and(data_get($bucketByName, 'Different Policy.bucket'))->toBe('ready') + ->and(data_get($bucketByName, 'Missing Policy.bucket'))->toBe('ready') + ->and(data_get($bucketByName, 'Manual Mapping Policy.bucket'))->toBe('manual_mapping_required') + ->and(data_get($bucketByName, 'Refresh Required Policy.bucket'))->toBe('blocked'); + + $identifierGap = collect($preflight['buckets']['blocked']) + ->first(fn (array $subject): bool => in_array('source_identifier_missing', data_get($subject, 'preflight.reasonCodes', []), true)); + + expect($identifierGap)->toBeArray() + ->and(data_get($identifierGap, 'preflight.reasonLabels.0'))->toBe('Source tenant subject is missing a stable compare identifier.') + ->and($preflight['blockedReasonCounts'])->toMatchArray([ + 'source_identifier_missing' => 1, + 'source_evidence_refresh_required' => 1, + 'target_subject_ambiguous' => 1, + ]); +}); + +it('remains read only when building a promotion preflight', function (): void { + $fixture = crossTenantPromotionFixture(); + + createPromotionSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Readonly Policy', + snapshot: ['settings' => [['key' => 'readonly', 'value' => 1]]], + ); + + $preview = app(CrossTenantComparePreviewBuilder::class)->build(new CrossTenantCompareSelection( + sourceTenant: $fixture['sourceTenant'], + targetTenant: $fixture['targetTenant'], + policyTypes: ['deviceConfiguration'], + )); + + $operationRunCount = OperationRun::query()->count(); + $policyVersionCount = PolicyVersion::query()->count(); + + app(CrossTenantPromotionPreflight::class)->build($preview); + + expect(OperationRun::query()->count())->toBe($operationRunCount) + ->and(PolicyVersion::query()->count())->toBe($policyVersionCount); +}); + +/** + * @return array{sourceTenant: Tenant, targetTenant: Tenant} + */ +function crossTenantPromotionFixture(): array +{ + $sourceTenant = Tenant::factory()->create(); + $targetTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $sourceTenant->workspace_id, + ]); + + return [ + 'sourceTenant' => $sourceTenant, + 'targetTenant' => $targetTenant, + ]; +} + +/** + * @param array $snapshot + * @return array{policy: Policy, version: PolicyVersion, inventory: InventoryItem} + */ +function createPromotionSubject( + Tenant $tenant, + string $displayName, + array $snapshot, + string $policyType = 'deviceConfiguration', + ?string $externalId = null, +): array { + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => $policyType, + 'external_id' => $externalId ?? str($displayName)->slug()->append('-')->append((string) str()->uuid())->toString(), + 'display_name' => $displayName, + 'platform' => 'windows', + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'policy_type' => $policyType, + 'platform' => 'windows', + 'captured_at' => now(), + 'snapshot' => $snapshot, + 'assignments' => [], + 'scope_tags' => [], + ]); + + $inventory = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_type' => $policyType, + 'external_id' => (string) $policy->external_id, + 'display_name' => $displayName, + 'last_seen_at' => now(), + ]); + + return [ + 'policy' => $policy, + 'version' => $version, + 'inventory' => $inventory, + ]; +} diff --git a/specs/043-cross-tenant-compare-and-promotion/checklists/requirements.md b/specs/043-cross-tenant-compare-and-promotion/checklists/requirements.md index df9f95c8..c78e00d6 100644 --- a/specs/043-cross-tenant-compare-and-promotion/checklists/requirements.md +++ b/specs/043-cross-tenant-compare-and-promotion/checklists/requirements.md @@ -1,7 +1,7 @@ # Specification Quality Checklist: Cross-Tenant Compare Preview and Promotion Preflight -**Purpose**: Validate full preparation-package completeness and implementation readiness before the feature moves into the implementation loop -**Created**: 2026-04-27 +**Purpose**: Validate full preparation-package completeness and implementation readiness before the feature moves into the implementation loop +**Created**: 2026-04-27 **Feature**: [spec.md](../spec.md) ## Content Quality @@ -54,4 +54,6 @@ ## Notes - This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and this checklist artifact. It does not claim that runtime code or a promotion workflow already exists. - The active slice stops before any target mutation, any queued execution, any persisted draft or compare snapshot, and any broader mapping automation. -- No new globally searchable resource is introduced, no new asset registration is expected, and deployment behavior remains unchanged unless a later implementation explicitly adds assets. \ No newline at end of file +- No new globally searchable resource is introduced, no new asset registration is expected, and deployment behavior remains unchanged unless a later implementation explicitly adds assets. +- Implementation sync on 2026-04-30 confirmed the code still honors those guardrails: the landed slice remains read-only, adds no compare resource to global search, and introduces no new asset registration. +- TEST-GOV-001 close-out for the landed slice stays `keep`: focused `Unit` + `Feature` proof only, with actual execution, mapping automation, and multi-provider compare explicitly deferred as follow-up work rather than hidden scope growth. diff --git a/specs/043-cross-tenant-compare-and-promotion/plan.md b/specs/043-cross-tenant-compare-and-promotion/plan.md index 2a1c7982..bae96691 100644 --- a/specs/043-cross-tenant-compare-and-promotion/plan.md +++ b/specs/043-cross-tenant-compare-and-promotion/plan.md @@ -1,6 +1,6 @@ # Implementation Plan: Cross-Tenant Compare Preview and Promotion Preflight -**Branch**: `043-cross-tenant-compare-and-promotion` | **Date**: 2026-04-27 | **Spec**: [spec.md](spec.md) +**Branch**: `043-cross-tenant-compare-and-promotion` | **Date**: 2026-04-30 | **Spec**: [spec.md](spec.md) **Input**: Feature specification from [spec.md](spec.md) ## Summary @@ -9,22 +9,45 @@ ## Summary Filament remains on Livewire v4, no panel-provider registration changes are required (`bootstrap/providers.php` remains the authoritative provider registration location), no globally searchable compare resource is added, and no new panel asset bundle is expected. +## Implementation Sync + +- Landed runtime artifacts: + - `App\Filament\Pages\CrossTenantComparePage` + - `App\Support\PortfolioCompare\CrossTenantCompareSelection` + - `App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder` + - `App\Support\PortfolioCompare\CrossTenantPromotionPreflight` + - tenant-registry row launch, exact-two bulk launch, and return wiring in `TenantResource` and `CanonicalNavigationContext` + - bounded preflight audit logging in `WorkspaceAuditLogger` and `AuditActionId` +- Landed validation artifacts: + - focused `Unit/Support/PortfolioCompare` tests for compare preview and promotion preflight + - focused `Feature/PortfolioCompare` tests for page rendering, auth semantics, audit semantics, and registry launch continuity +- Confirmed implementation constraints: + - read-only only; no target mutation, queue, or `OperationRun` + - no new asset registration + - no new globally searchable resource + - admin panel provider registration remains unchanged outside explicit page registration in Filament's admin panel provider +- Deferred follow-up remains unchanged: + - actual promotion execution + - persisted promotion drafts or compare snapshots + - mapping automation + - multi-provider compare + ## Technical Context -**Language/Version**: PHP 8.4, Laravel 12 -**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing baseline compare services, portfolio-triage seams, audit services, and capability resolvers -**Storage**: PostgreSQL via existing inventory, policy-version, and audit tables; no new compare or promotion table -**Testing**: Pest v4 `Unit` and `Feature` coverage only -**Validation Lanes**: fast-feedback, confidence -**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`) -**Project Type**: Web application (Laravel monolith with Filament pages) -**Performance Goals**: compare preview and promotion preflight stay synchronous and derived from existing persisted truth; no background execution path in v1 -**Constraints**: no target mutation, no `OperationRun`, no queue, no new persisted draft, no cross-workspace compare, no raw payload view by default +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing baseline compare services, portfolio-triage seams, audit services, and capability resolvers +**Storage**: PostgreSQL via existing inventory, policy-version, and audit tables; no new compare or promotion table +**Testing**: Pest v4 `Unit` and `Feature` coverage only +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`) +**Project Type**: Web application (Laravel monolith with Filament pages) +**Performance Goals**: compare preview and promotion preflight stay synchronous and derived from existing persisted truth; no background execution path in v1 +**Constraints**: no target mutation, no `OperationRun`, no queue, no new persisted draft, no cross-workspace compare, no raw payload view by default **Scale/Scope**: 2 tenant selectors, 1 canonical compare page, 1 preflight action, 1 launch/return continuity path, focused reuse of existing compare builders ## UI / Surface Guardrail Plan -- **Guardrail scope**: one new canonical compare page plus one launch action from existing tenant-registry/portfolio context +- **Guardrail scope**: one new canonical compare page plus bounded row and exact-two bulk launch actions from existing tenant-registry/portfolio context - **Native vs custom classification summary**: native Filament page with shared compare/audit/navigation primitives - **Shared-family relevance**: canonical admin pages, compare drill-down patterns, launch actions, audit-backed modal/action copy - **State layers in scope**: page, query state @@ -32,7 +55,7 @@ ## UI / Surface Guardrail Plan - **Decision/diagnostic/raw hierarchy plan**: decision-first compare summary, diagnostics second, raw evidence stays on existing tenant/baseline surfaces - **Raw/support gating plan**: no new raw/support surface; keep payload proof behind existing pages - **One-primary-action / duplicate-truth control**: the compare page keeps one dominant next action, `Generate promotion preflight`; drill-down and return actions stay secondary -- **Launch default**: the tenant-registry launch action prefills the launched tenant as `target tenant`; the operator chooses the source tenant explicitly +- **Launch default**: the row launch prefills the launched tenant as `target tenant`; the exact-two bulk launch prefills both selected tenants while preserving the same registry return context - **Handling modes by drift class or surface**: review-mandatory; any actual promotion execution or queue path is exception-required and out of scope - **Repository-signal treatment**: review-mandatory - **Special surface test profiles**: standard-native-filament diff --git a/specs/043-cross-tenant-compare-and-promotion/spec.md b/specs/043-cross-tenant-compare-and-promotion/spec.md index 3adb2752..a5c45c37 100644 --- a/specs/043-cross-tenant-compare-and-promotion/spec.md +++ b/specs/043-cross-tenant-compare-and-promotion/spec.md @@ -1,11 +1,21 @@ # Feature Specification: Cross-Tenant Compare Preview and Promotion Preflight -**Feature Branch**: `043-cross-tenant-compare-and-promotion` -**Created**: 2026-01-07 -**Updated**: 2026-04-27 -**Status**: Ready for implementation +**Feature Branch**: `043-cross-tenant-compare-and-promotion` +**Created**: 2026-01-07 +**Updated**: 2026-04-30 +**Status**: Implemented (read-only slice) **Input**: Refresh existing Spec 043 against `docs/product/spec-candidates.md`, `docs/product/implementation-ledger.md`, and `docs/product/roadmap.md` so the feature becomes a narrow, implementation-ready slice instead of a broad future ambition. +## Implementation Sync *(2026-04-30)* + +- The canonical admin compare surface is implemented as `CrossTenantComparePage` under `/admin/cross-tenant-compare` with shareable query state, direct tenant drill-down links, and one dominant read-only action: `Generate promotion preflight`. +- The reusable compare contract is implemented in `App\Support\PortfolioCompare\CrossTenantCompareSelection`, `CrossTenantComparePreviewBuilder`, and `CrossTenantPromotionPreflight`. +- Portfolio launch continuity is implemented from the tenant registry via a bounded row-level `Compare tenants` action, an exact-two bulk compare launch, and `CanonicalNavigationContext` return-state wiring. +- Preflight audit is implemented through the existing workspace audit pipeline using `AuditActionId::CrossTenantPromotionPreflightGenerated` and `WorkspaceAuditLogger`. +- The focused `Unit` + `Feature` PortfolioCompare suite is green for compare preview, preflight, authorization, audit, and launch/return continuity. +- Explicitly deferred and still out of scope: actual promotion execution, target mutation, queueing, `OperationRun`, persisted compare snapshots, persisted promotion drafts, mapping automation, customer-facing compare, and multi-provider compare. +- Guardrails remain unchanged in implementation: Filament v5 on Livewire v4, provider registration stays in `bootstrap/providers.php`, no globally searchable compare resource was introduced, and no new asset registration was added. + ## Spec Candidate Check *(mandatory - SPEC-GATE-001)* - **Problem**: TenantPilot now has portfolio visibility, triage continuity, and strong tenant-level baseline compare surfaces, but operators still lack one canonical workspace-level path to compare a source tenant to a target tenant and prepare a safe promotion decision. @@ -98,7 +108,7 @@ ## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are chang | Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | |---|---|---|---|---|---|---|---| | Canonical cross-tenant compare page | operator-MSP | source/target summary, compare counts, preflight readiness summary, top blocked reasons | subject-level mapping gaps and deep links to tenant-specific evidence | raw payloads remain on existing tenant/baseline pages, not this surface | `Generate promotion preflight` | raw JSON, provider IDs, and low-level evidence stay behind existing detail pages | compare page states the decision truth once; drill-down pages add proof rather than rephrasing the same blocker | -| Tenant registry / portfolio launch action | operator-MSP | current tenant context and compare launch intent | return-state token only | none | `Compare tenants` | any future write action remains absent | launch action does not duplicate compare summaries on the registry row | +| Tenant registry / portfolio launch action | operator-MSP | current tenant context and compare launch intent | return-state token only | none | `Compare tenants` / `Compare selected` | any future write action remains absent | launch action does not duplicate compare summaries on the registry row | ## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* @@ -112,7 +122,7 @@ ## Operator Surface Contract *(mandatory when operator-facing surfaces are chang | 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 | |---|---|---|---|---|---|---|---|---|---|---| | Canonical cross-tenant compare page | Workspace operator / MSP operator | Decide whether a target tenant is ready for a later promotion workflow | Canonical decision page | Can this target tenant safely follow the selected source tenant for the chosen governed subjects? | source/target summary, compare counts, blocked reasons, ready/manual counts, and next action | subject-level mappings, stale evidence signals, and deep links to existing tenant compare/detail surfaces | compare state, readiness, mapping confidence, evidence freshness | TenantPilot only in v1 | Generate promotion preflight, open source tenant, open target tenant | none | -| Tenant registry / portfolio launch action | Workspace operator / MSP operator | Start compare from an existing portfolio review path | Registry action | Which tenant should I compare next without losing context? | current tenant identity and compare launch intent | preserved triage filters and return token | launch context only | none | Compare tenants | none | +| Tenant registry / portfolio launch action | Workspace operator / MSP operator | Start compare from an existing portfolio review path | Registry action | Which tenants should I compare next without losing context? | current tenant identity and compare launch intent | preserved triage filters and return token | launch context only | none | Compare tenants / Compare selected | none | ## Proportionality Review *(mandatory when structural complexity is introduced)* @@ -242,7 +252,7 @@ ### User Story 3 - Launch compare from portfolio context without losing return s **Acceptance Scenarios**: -1. **Given** the tenant registry has active portfolio-triage filters, **When** the operator launches compare from a tenant row or contextual action, **Then** the compare page preserves a return token and prefills the launched tenant as the `target tenant`. +1. **Given** the tenant registry has active portfolio-triage filters, **When** the operator launches compare from a tenant row or an exact-two bulk selection, **Then** the compare page preserves a return token and prefills the launched tenant context without dropping the current registry filters. 2. **Given** the operator returns from compare, **When** the registry reloads, **Then** the prior triage filters are restored. ### Edge Cases diff --git a/specs/043-cross-tenant-compare-and-promotion/tasks.md b/specs/043-cross-tenant-compare-and-promotion/tasks.md index f3176f9d..77e10304 100644 --- a/specs/043-cross-tenant-compare-and-promotion/tasks.md +++ b/specs/043-cross-tenant-compare-and-promotion/tasks.md @@ -6,31 +6,31 @@ # Tasks: Cross-Tenant Compare Preview and Promotion Preflight -**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/` +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/` **Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` (required) -**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage by default for this first read-only slice. -**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Promotion remains preflight-only. -**RBAC**: Existing workspace and tenant membership semantics remain authoritative. Non-members or actors lacking source or target tenant entitlement receive `404`; members who reach the canonical compare surface but lack the required capability receive `403`. Page access uses `Capabilities::WORKSPACE_BASELINES_VIEW`, preview data uses `Capabilities::TENANT_VIEW` on both tenants, and preflight execution adds `Capabilities::WORKSPACE_BASELINES_MANAGE`. -**Provider Boundary**: The page contract stays platform-neutral (`source tenant`, `target tenant`, `governed subject`, `promotion preflight`) while reusing Microsoft-first inventory and baseline compare seams under the hood. +**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage by default for this first read-only slice. +**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Promotion remains preflight-only. +**RBAC**: Existing workspace and tenant membership semantics remain authoritative. Non-members or actors lacking source or target tenant entitlement receive `404`; members who reach the canonical compare surface but lack the required capability receive `403`. Page access uses `Capabilities::WORKSPACE_BASELINES_VIEW`, preview data uses `Capabilities::TENANT_VIEW` on both tenants, and preflight execution adds `Capabilities::WORKSPACE_BASELINES_MANAGE`. +**Provider Boundary**: The page contract stays platform-neutral (`source tenant`, `target tenant`, `governed subject`, `promotion preflight`) while reusing Microsoft-first inventory and baseline compare seams under the hood. **Organization**: Tasks are grouped by user story so compare preview, promotion preflight, and portfolio launch continuity remain independently testable once the shared contracts exist. ## Test Governance Checklist -- [ ] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior. -- [ ] New or changed tests stay in `apps/platform/tests/Unit/Support/PortfolioCompare/` and `apps/platform/tests/Feature/PortfolioCompare/` only. -- [ ] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or seeded promotion history. -- [ ] Planned validation commands cover compare preview, promotion preflight, launch continuity, audit, and authorization without widening scope. -- [ ] The declared surface test profile remains `standard-native-filament` because the slice adds one canonical page and one launch action on existing surfaces. -- [ ] Any deferred execution, mapping automation, or multi-provider follow-up resolves as `document-in-feature` or `follow-up-spec`, not hidden scope growth. +- [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior. +- [x] New or changed tests stay in `apps/platform/tests/Unit/Support/PortfolioCompare/` and `apps/platform/tests/Feature/PortfolioCompare/` only. +- [x] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or seeded promotion history. +- [x] Planned validation commands cover compare preview, promotion preflight, launch continuity, audit, and authorization without widening scope. +- [x] The declared surface test profile remains `standard-native-filament` because the slice adds one canonical page and one launch action on existing surfaces. +- [x] Any deferred execution, mapping automation, or multi-provider follow-up resolves as `document-in-feature` or `follow-up-spec`, not hidden scope growth. ## Phase 1: Setup (Shared Context) **Purpose**: Confirm the narrowed slice, the reusable compare seams, and the reviewer stop conditions before implementation begins. -- [ ] T001 Review the narrowed compare-preview and promotion-preflight slice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` together with the current candidate and ledger references. -- [ ] T002 [P] Confirm the compare and subject-identity seams that this slice must reuse in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Services/Baselines/BaselineCompareService.php`, `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`, `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php`, `apps/platform/app/Models/InventoryItem.php`, and `apps/platform/app/Models/PolicyVersion.php`. -- [ ] T003 [P] Confirm the portfolio launch, authorization, and audit seams that this slice must reuse in `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`, `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Services/Auth/CapabilityResolver.php`, and `apps/platform/app/Services/Auth/WorkspaceCapabilityResolver.php`. +- [x] T001 Review the narrowed compare-preview and promotion-preflight slice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` together with the current candidate and ledger references. +- [x] T002 [P] Confirm the compare and subject-identity seams that this slice must reuse in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Services/Baselines/BaselineCompareService.php`, `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`, `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php`, `apps/platform/app/Models/InventoryItem.php`, and `apps/platform/app/Models/PolicyVersion.php`. +- [x] T003 [P] Confirm the portfolio launch, authorization, and audit seams that this slice must reuse in `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`, `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Services/Auth/CapabilityResolver.php`, and `apps/platform/app/Services/Auth/WorkspaceCapabilityResolver.php`. --- @@ -40,11 +40,11 @@ ## Phase 2: Foundational (Blocking Prerequisites) **Critical**: No user-story work should begin until this phase is complete. -- [ ] T004 [P] Define the minimal compare-page input/output shape inside `apps/platform/app/Support/PortfolioCompare/` or `apps/platform/app/Services/PortfolioCompare/` for source tenant, target tenant, governed-subject filters, and preflight output without adding a wider DTO or resolver framework. -- [ ] T005 [P] Implement source-plus-target entitlement checks inside the canonical compare page and shared preview/preflight services using the existing capability resolvers so workspace membership, source entitlement, target entitlement, and capability denial all follow existing `404`/`403` semantics. -- [ ] T006 Implement the compare preview builder so it reuses stable governed-subject identity from existing inventory and baseline compare seams and produces a canonical preview summary without storing new compare truth. -- [ ] T007 Implement the promotion-preflight service so it classifies governed subjects as ready, blocked, or manual-mapping-required and explicitly performs no target mutation, queue dispatch, or `OperationRun` creation. -- [ ] T008 [P] Add bounded preflight audit action IDs and metadata shaping in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` for promotion-preflight entry points only. +- [x] T004 [P] Define the minimal compare-page input/output shape inside `apps/platform/app/Support/PortfolioCompare/` or `apps/platform/app/Services/PortfolioCompare/` for source tenant, target tenant, governed-subject filters, and preflight output without adding a wider DTO or resolver framework. +- [x] T005 [P] Implement source-plus-target entitlement checks inside the canonical compare page and shared preview/preflight services using the existing capability resolvers so workspace membership, source entitlement, target entitlement, and capability denial all follow existing `404`/`403` semantics. +- [x] T006 Implement the compare preview builder so it reuses stable governed-subject identity from existing inventory and baseline compare seams and produces a canonical preview summary without storing new compare truth. +- [x] T007 Implement the promotion-preflight service so it classifies governed subjects as ready, blocked, or manual-mapping-required and explicitly performs no target mutation, queue dispatch, or `OperationRun` creation. +- [x] T008 [P] Add bounded preflight audit action IDs and metadata shaping in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` for promotion-preflight entry points only. **Checkpoint**: Shared compare scope, entitlement resolution, preview building, preflight classification, and audit metadata exist; user stories can proceed independently. @@ -58,15 +58,15 @@ ## Phase 3: User Story 1 - Compare Two Authorized Tenants (Priority: P1) MVP ### Tests for User Story 1 -- [ ] T009 [P] [US1] Add feature coverage for rendering the canonical compare page, selecting source and target tenants, rejecting same-tenant selection, and showing one default-visible compare summary with no duplicate decision truth or raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`. -- [ ] T010 [P] [US1] Add feature coverage for `404` vs `403` semantics across source/target entitlement, workspace `WORKSPACE_BASELINES_VIEW`, tenant `TENANT_VIEW`, visible-disabled preflight UX for members lacking `WORKSPACE_BASELINES_MANAGE`, and server-side denial of forced preflight requests in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`. -- [ ] T011 [P] [US1] Add unit coverage for compare preview subject matching and reproducible summary output in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php`. +- [x] T009 [P] [US1] Add feature coverage for rendering the canonical compare page, selecting source and target tenants, rejecting same-tenant selection, and showing one default-visible compare summary with no duplicate decision truth or raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`. +- [x] T010 [P] [US1] Add feature coverage for `404` vs `403` semantics across source/target entitlement, workspace `WORKSPACE_BASELINES_VIEW`, tenant `TENANT_VIEW`, visible-disabled preflight UX for members lacking `WORKSPACE_BASELINES_MANAGE`, and server-side denial of forced preflight requests in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`. +- [x] T011 [P] [US1] Add unit coverage for compare preview subject matching and reproducible summary output in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php`. ### Implementation for User Story 1 -- [ ] T012 [US1] Add the canonical compare page under `apps/platform/app/Filament/Pages/` with source/target selectors, governed-subject filters, shareable query state, and compare preview summary built from the shared preview builder. -- [ ] T013 [US1] Reuse existing baseline compare and inventory seams so the compare page deep-links to tenant-level proof surfaces instead of duplicating raw diagnostics. -- [ ] T014 [US1] Keep page copy, chips, and summary wording aligned to `source tenant`, `target tenant`, `governed subject`, and `compare preview` rather than Microsoft-first or execution-first vocabulary. +- [x] T012 [US1] Add the canonical compare page under `apps/platform/app/Filament/Pages/` with source/target selectors, governed-subject filters, shareable query state, and compare preview summary built from the shared preview builder. +- [x] T013 [US1] Reuse existing baseline compare and inventory seams so the compare page deep-links to tenant-level proof surfaces instead of duplicating raw diagnostics. +- [x] T014 [US1] Keep page copy, chips, and summary wording aligned to `source tenant`, `target tenant`, `governed subject`, and `compare preview` rather than Microsoft-first or execution-first vocabulary. **Checkpoint**: User Story 1 is independently functional when the canonical page produces a reproducible compare preview for two authorized tenants. @@ -80,15 +80,15 @@ ## Phase 4: User Story 2 - Generate a Read-Only Promotion Preflight (Priority: P ### Tests for User Story 2 -- [ ] T015 [P] [US2] Add unit coverage for preflight classification across ready, blocked, manual-mapping, stale-evidence, and missing-identifier cases in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`. -- [ ] T016 [P] [US2] Add feature coverage for the compare page's `Generate promotion preflight` action, visible-disabled manage-denial UX, one dominant next action, visible readiness summary, and no default-visible raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`. -- [ ] T017 [P] [US2] Add feature coverage for preflight audit metadata and explicit no-write semantics in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`. +- [x] T015 [P] [US2] Add unit coverage for preflight classification across ready, blocked, manual-mapping, stale-evidence, and missing-identifier cases in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`. +- [x] T016 [P] [US2] Add feature coverage for the compare page's `Generate promotion preflight` action, visible-disabled manage-denial UX, one dominant next action, visible readiness summary, and no default-visible raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`. +- [x] T017 [P] [US2] Add feature coverage for preflight audit metadata and explicit no-write semantics in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`. ### Implementation for User Story 2 -- [ ] T018 [US2] Add the read-only `Generate promotion preflight` action to the canonical compare page, keeping it distinct from any future execution action and free of queue/runtime side effects. -- [ ] T019 [US2] Render a promotion-preflight summary that groups governed subjects into ready, blocked, and manual-mapping-required buckets with explicit operator-readable reasons. -- [ ] T020 [US2] Route preflight entry-point audit through the existing workspace audit pipeline with source tenant, target tenant, subject counts, and blocked-reason metadata only. +- [x] T018 [US2] Add the read-only `Generate promotion preflight` action to the canonical compare page, keeping it distinct from any future execution action and free of queue/runtime side effects. +- [x] T019 [US2] Render a promotion-preflight summary that groups governed subjects into ready, blocked, and manual-mapping-required buckets with explicit operator-readable reasons. +- [x] T020 [US2] Route preflight entry-point audit through the existing workspace audit pipeline with source tenant, target tenant, subject counts, and blocked-reason metadata only. **Checkpoint**: User Story 2 is independently functional when the operator can generate an audited, read-only readiness decision from the compare page. @@ -102,13 +102,13 @@ ## Phase 5: User Story 3 - Launch Compare from Portfolio Context Without Losing ### Tests for User Story 3 -- [ ] T021 [P] [US3] Add feature coverage for compare launch and return continuity from the tenant registry / portfolio-triage path in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`. -- [ ] T022 [P] [US3] Extend authorization coverage so launch actions only appear or resolve when the current actor is entitled to the launched tenant and the compare surface in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`. +- [x] T021 [P] [US3] Add feature coverage for compare launch and return continuity from the tenant registry / portfolio-triage path in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`. +- [x] T022 [P] [US3] Extend authorization coverage so launch actions only appear or resolve when the current actor is entitled to the launched tenant and the compare surface in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`. ### Implementation for User Story 3 -- [ ] T023 [US3] Add a bounded launch action from `apps/platform/app/Filament/Resources/TenantResource.php` or `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` that opens the canonical compare page with the current tenant prefilled as the `target tenant`. -- [ ] T024 [US3] Preserve and restore portfolio-triage return state using the existing navigation-context pattern rather than a page-local custom token format. +- [x] T023 [US3] Add bounded registry launch actions from `apps/platform/app/Filament/Resources/TenantResource.php` or `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` so row launch can prefill the current tenant as the `target tenant` and exact-two bulk launch can prefill both selected tenants. +- [x] T024 [US3] Preserve and restore portfolio-triage return state using the existing navigation-context pattern rather than a page-local custom token format. **Checkpoint**: User Story 3 is independently functional when compare can be launched from portfolio context and the operator can return without losing triage filters. @@ -118,11 +118,11 @@ ## Phase 6: Polish & Cross-Cutting Concerns **Purpose**: Finish narrow validation and reviewer close-out without widening scope. -- [ ] T025 [P] Run the focused unit validation commands for `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php` and `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`. -- [ ] T026 [P] Run the focused feature validation commands for `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`, and `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`. -- [ ] T027 Run dirty-only formatting for touched platform files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. -- [ ] T028 [P] Add or update the checklist/reviewer guard confirming that this slice introduces no new asset registration and no globally searchable resource. -- [ ] T029 Record TEST-GOV-001 close-out and any `document-in-feature` or `follow-up-spec` deferrals for actual execution, mapping automation, or multi-provider compare in the active feature PR or implementation notes. +- [x] T025 [P] Run the focused unit validation commands for `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php` and `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`. +- [x] T026 [P] Run the focused feature validation commands for `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`, and `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`. +- [x] T027 Run dirty-only formatting for touched platform files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. +- [x] T028 [P] Add or update the checklist/reviewer guard confirming that this slice introduces no new asset registration and no globally searchable resource. +- [x] T029 Record TEST-GOV-001 close-out and any `document-in-feature` or `follow-up-spec` deferrals for actual execution, mapping automation, or multi-provider compare in the active feature PR or implementation notes. --- -- 2.45.2 From e1136ac6e9026219523e217ed5f8d53fd3fead82 Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 30 Apr 2026 14:41:01 +0000 Subject: [PATCH 34/36] Merge platform-dev into dev (automated) (#309) Automatischer Commit und PR erstellt auf Anfrage. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/309 --- .specify/research_t186.md | 5 + docs/HANDOVER.md | 5 + docs/PERMISSIONS.md | 198 +++++--------- docs/PROJECT_SUMMARY.md | 5 + docs/README.md | 60 +++++ .../2026-03-15-audit-spec-candidates.md | 5 + docs/audits/README.md | 10 + ...nterprise-architecture-audit-2026-03-09.md | 5 + docs/audits/legacy-orphaned-truth-audit.md | 5 + .../semantic-clarity-spec-candidates.md | 5 + ...ntpilot-architecture-audit-constitution.md | 5 + docs/product/discoveries.md | 44 +--- docs/product/implementation-ledger.md | 43 +-- docs/product/operator-semantic-taxonomy.md | 5 + docs/product/principles.md | 5 + docs/product/prompts/README.md | 11 + docs/product/roadmap.md | 98 +++++-- docs/product/spec-candidates.md | 248 ++++++++++++++---- .../admin-canonical-tenant-rollout.md | 5 + .../canonical-tenant-context-resolution.md | 5 + docs/research/filament-v5-notes.md | 5 + ...den-master-baseline-drift-deep-analysis.md | 5 + .../m365-policy-coverage-gap-analysis.md | 5 + docs/security/REDACTION_AUDIT_REPORT.md | 5 + docs/strategy/domain-coverage.md | 5 + docs/strategy/product-vision.md | 27 ++ docs/strategy/website-working-contract.md | 5 + docs/ui/action-surface-contract.md | 5 + docs/ui/filament-table-standard.md | 5 + docs/ui/operator-ux-surface-standards.md | 5 + .../ui/shared-diff-presentation-foundation.md | 5 + spechistory/spec.md | 5 + .../IMPLEMENTATION_STATUS.md | 5 + .../MANUAL_TESTING_CHECKLIST.md | 5 + specs/feat/700-bugfix/plan.md | 26 -- specs/feat/700-bugfix/spec.md | 29 -- specs/feat/700-bugfix/tasks.md | 20 -- 37 files changed, 605 insertions(+), 334 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/audits/README.md create mode 100644 docs/product/prompts/README.md delete mode 100644 specs/feat/700-bugfix/plan.md delete mode 100644 specs/feat/700-bugfix/spec.md delete mode 100644 specs/feat/700-bugfix/tasks.md diff --git a/.specify/research_t186.md b/.specify/research_t186.md index 23773aa9..f1b5d9ea 100644 --- a/.specify/research_t186.md +++ b/.specify/research_t186.md @@ -1,5 +1,10 @@ # Research T186 — settings_apply capability verification (LEGACY / DEPRECATED) +> **Status:** Superseded +> **Last reviewed:** 2026-04-30 +> **Use for:** Historical investigation context only if a later Settings Catalog write-path regression needs provenance +> **Do not use for:** Active feature research or current implementation truth + > DEPRECATED: Do not add new research notes under `.specify/`. > Active feature research should live under `specs/-/`. > Legacy history lives under `spechistory/`. diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 06362df3..7352a629 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,5 +1,10 @@ # TenantPilot / TenantAtlas — Handover Document +> **Status:** Needs Review +> **Last reviewed:** 2026-04-30 +> **Use for:** Handover context, repo snapshot orientation, and migration-era operational notes +> **Do not use for:** Current implementation truth or current branch state without repo verification + > **Generated**: 2026-03-06 · **Branch**: `dev` · **HEAD**: `da1adbd` > **Stack**: Laravel 12 · Filament v5 · Livewire v4 · PostgreSQL 16 · Tailwind v4 · Pest 4 diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 6eabcb8b..9a904612 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -1,154 +1,90 @@ # Microsoft Graph API Permissions -This document lists all required Microsoft Graph API permissions for TenantPilot to function correctly. +> **Status:** Reference +> **Last reviewed:** 2026-04-30 +> **Use for:** Current repo-based Microsoft Graph permission reference for implemented platform features +> **Do not use for:** Future roadmap permissions or final tenant-specific grant truth without checking the repo and the live tenant posture -## Required Permissions +This document summarizes the permission registry currently defined in: -The Azure AD / Entra ID **App Registration** used by TenantPilot requires the following **Application Permissions** (not Delegated): +- `apps/platform/config/intune_permissions.php` +- `apps/platform/config/entra_permissions.php` -### Core Policy Management (Required) -- `DeviceManagementConfiguration.Read.All` - Read Intune device configuration policies -- `DeviceManagementConfiguration.ReadWrite.All` - Write/restore Intune policies -- `DeviceManagementApps.Read.All` - Read app configuration policies -- `DeviceManagementApps.ReadWrite.All` - Write app policies +These config files are the repo source of truth for currently implemented permission requirements. -### Scope Tags (Feature 004 - Required for Phase 3) -- **`DeviceManagementRBAC.Read.All`** - Read scope tags and RBAC settings - - **Purpose**: Resolve scope tag IDs to display names (e.g., "0" → "Default") - - **Missing**: Backup items will show "Unknown (ID: 0)" instead of scope tag names - - **Impact**: Metadata display only - backups still work without this permission +## Scope Rules -### Group Resolution (Feature 004 - Required for Phase 2) -- `Group.Read.All` - Resolve group IDs to names for assignments -- `Directory.Read.All` - Batch resolve directory objects (groups, users, devices) +- The list below describes the current repo-required Microsoft Graph permissions for implemented features. +- This document does not promote roadmap or research-only permissions to required status. +- `granted_stub` values in `intune_permissions.php` are display aids for the UI, not the canonical required-permission list. +- Unless stated otherwise, these are application permissions. -## How to Add Permissions +## Current Required Permissions -### Azure Portal (Entra ID) +### Intune Configuration, Backup, Restore, and Drift -1. Go to **Azure Portal** → **Entra ID** (Azure Active Directory) -2. Navigate to **App registrations** → Select your TenantPilot app -3. Click **API permissions** in the left menu -4. Click **+ Add a permission** -5. Select **Microsoft Graph** → **Application permissions** -6. Search for and select the required permissions: - - `DeviceManagementRBAC.Read.All` - - (Add others as needed) -7. Click **Add permissions** -8. **IMPORTANT**: Click **Grant admin consent for [Your Organization]** - - ⚠️ Without admin consent, the permissions won't be active! +| Permission | Why the repo requires it | +|---|---| +| `DeviceManagementConfiguration.Read.All` | Read Intune device configuration policies for inventory, backup, settings normalization, and drift flows | +| `DeviceManagementConfiguration.ReadWrite.All` | Execute restore and other write flows for Intune device configuration policies | +| `DeviceManagementApps.Read.All` | Read Intune app configuration and assignments for sync and backup | +| `DeviceManagementApps.ReadWrite.All` | Restore and manage Intune app configuration and assignments | +| `DeviceManagementServiceConfig.Read.All` | Read enrollment restrictions, Autopilot, ESP, and related service configuration | +| `DeviceManagementServiceConfig.ReadWrite.All` | Restore and manage enrollment restrictions, Autopilot, ESP, and related service configuration | +| `DeviceManagementScripts.Read.All` | Read device management scripts and remediations for sync and backup | +| `DeviceManagementScripts.ReadWrite.All` | Restore and manage device management scripts and remediations | -### PowerShell (Alternative) +### Conditional Access And Policy Coverage -```powershell -# Connect to Microsoft Graph -Connect-MgGraph -Scopes "Application.ReadWrite.All" +| Permission | Why the repo requires it | +|---|---| +| `Policy.Read.All` | Read Conditional Access and related identity policy surfaces used for backup, preview, and versioning | +| `Policy.ReadWrite.ConditionalAccess` | Manage Conditional Access policies for controlled restore or admin-managed write paths | -# Get your app registration -$appId = "YOUR-APP-CLIENT-ID" -$app = Get-MgApplication -Filter "appId eq '$appId'" +### Directory, Groups, And Intune RBAC Foundations -# Add DeviceManagementRBAC.Read.All permission -$graphServicePrincipal = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" -$rbacPermission = $graphServicePrincipal.AppRoles | Where-Object {$_.Value -eq "DeviceManagementRBAC.Read.All"} +| Permission | Why the repo requires it | +|---|---| +| `Directory.Read.All` | Directory lookups and tenant-health-oriented checks | +| `Group.Read.All` | Assignment name resolution, group mapping, group directory cache, backup metadata enrichment, and drift context | +| `DeviceManagementRBAC.Read.All` | Read Intune RBAC settings and scope tags for metadata enrichment and assignment-aware flows | +| `DeviceManagementRBAC.ReadWrite.All` | Manage scope tags for foundation backup and restore workflows | -$requiredResourceAccess = @{ - ResourceAppId = "00000003-0000-0000-c000-000000000000" - ResourceAccess = @( - @{ - Id = $rbacPermission.Id - Type = "Role" - } - ) -} +### Entra Admin Roles Evidence -Update-MgApplication -ApplicationId $app.Id -RequiredResourceAccess $requiredResourceAccess +| Permission | Why the repo requires it | +|---|---| +| `RoleManagement.Read.Directory` | Read directory role definitions and assignments for Entra admin roles evidence and findings | -# Grant admin consent -# (Must be done manually or via Graph API with RoleManagement.ReadWrite.Directory scope) +## Not Currently Required By Implemented Features + +These permissions may appear in research, roadmap ideas, or tenant-specific grants, but they are not part of the current required-permission registry: + +- `SharePointTenantSettings.Read.All` is a roadmap or research permission until SharePoint tenant settings are actually implemented. +- Exchange Online or Defender for Office 365 PowerShell permissions are not current repo requirements because those integrations are not implemented as production features. +- `DeviceManagementManagedDevices.ReadWrite.All` may appear in fixtures or grant stubs, but it is not listed in the current required-permission registry. + +## Grant And Verify + +1. In Entra ID, open the TenantPilot app registration. +2. Add the required Microsoft Graph application permissions from the tables above. +3. Grant admin consent for the tenant. +4. In the application, use the required-permissions or permission-posture surfaces to compare granted versus required permissions. +5. If the platform still shows stale permission state, clear caches with: + +```bash +cd apps/platform && ./vendor/bin/sail artisan cache:clear ``` -## Verification +## Least-Privilege Notes -After adding permissions and granting admin consent: - -1. Go to **App registrations** → Your app → **API permissions** -2. Verify status shows **Granted for [Your Organization]** with a green checkmark ✅ -3. Clear cache in TenantPilot: - ```bash - php artisan cache:clear - ``` -4. Test scope tag resolution: - ```bash - php artisan tinker - >>> use App\Services\Graph\ScopeTagResolver; - >>> use App\Models\Tenant; - >>> $tenant = Tenant::first(); - >>> $resolver = app(ScopeTagResolver::class); - >>> $tags = $resolver->resolve(['0'], $tenant); - >>> dd($tags); - ``` - Expected output: - ```php - [ - [ - "id" => "0", - "displayName" => "Default" - ] - ] - ``` - -## Troubleshooting - -### Error: "Application is not authorized to perform this operation" - -**Symptoms:** -- Backup items show "Unknown (ID: 0)" for scope tags -- Logs contain: `Application must have one of the following scopes: DeviceManagementRBAC.Read.All` - -**Solution:** -1. Add `DeviceManagementRBAC.Read.All` permission (see above) -2. **Grant admin consent** (critical step!) -3. Wait 5-10 minutes for Azure to propagate permissions -4. Clear cache: `php artisan cache:clear` -5. Test again - -### Error: "Insufficient privileges to complete the operation" - -**Cause:** The user account used to grant admin consent doesn't have sufficient permissions. - -**Solution:** -- Use an account with **Global Administrator** or **Privileged Role Administrator** role -- Or have the IT admin grant consent for the organization - -### Permissions showing but still getting 403 - -**Possible causes:** -1. Admin consent not granted (click the button!) -2. Permissions not yet propagated (wait 5-10 minutes) -3. Wrong tenant (check tenant ID in app config) -4. Cached token needs refresh (clear cache + restart) - -## Feature Impact Matrix - -| Feature | Required Permissions | Without Permission | Impact Level | -|---------|---------------------|-------------------|--------------| -| Basic Policy Backup | `DeviceManagementConfiguration.Read.All` | Cannot backup | 🔴 Critical | -| Policy Restore | `DeviceManagementConfiguration.ReadWrite.All` | Cannot restore | 🔴 Critical | -| Scope Tag Names (004) | `DeviceManagementRBAC.Read.All` | Shows "Unknown (ID: X)" | 🟡 Medium | -| Assignment Names (004) | `Group.Read.All` + `Directory.Read.All` | Shows group IDs only | 🟡 Medium | -| Group Mapping (004) | `Group.Read.All` | Manual ID mapping required | 🟡 Medium | - -## Security Notes - -- All permissions are **Application Permissions** (app-level, not user-level) -- Requires **admin consent** from Global Administrator -- Use **least privilege principle**: Only add permissions for features you use -- Consider creating separate app registrations for different environments (dev/staging/prod) -- Rotate client secrets regularly (recommended: every 6 months) +- Read-only evaluation or inventory-focused setups can often begin with the read permissions only. +- Any real restore or write lane needs the corresponding `ReadWrite` permission set. +- Conditional Access write access should be treated as a higher-risk permission and granted only when the restore or admin-write lane is intentionally enabled. +- Scope-tag restore paths require `DeviceManagementRBAC.ReadWrite.All`, not just the read permission. ## References -- [Microsoft Graph API Permissions](https://learn.microsoft.com/en-us/graph/permissions-reference) -- [Intune Graph API Overview](https://learn.microsoft.com/en-us/graph/api/resources/intune-graph-overview) -- [App Registration Best Practices](https://learn.microsoft.com/en-us/azure/active-directory/develop/security-best-practices-for-app-registration) +- [Microsoft Graph permissions reference](https://learn.microsoft.com/en-us/graph/permissions-reference) +- [Microsoft Intune Graph overview](https://learn.microsoft.com/en-us/graph/api/resources/intune-graph-overview) +- [App registration security best practices](https://learn.microsoft.com/en-us/azure/active-directory/develop/security-best-practices-for-app-registration) diff --git a/docs/PROJECT_SUMMARY.md b/docs/PROJECT_SUMMARY.md index 7fa84041..e90bc67f 100644 --- a/docs/PROJECT_SUMMARY.md +++ b/docs/PROJECT_SUMMARY.md @@ -2,6 +2,11 @@ --- +> **Status:** Needs Review +> **Last reviewed:** 2026-04-30 +> **Use for:** Fast repository orientation, stack overview, and high-level product scope context +> **Do not use for:** Current implementation truth or completion status without repo verification + **Overview:** - **Purpose:** Intune management tool (backup, restore, policy versioning, safe change management) built with Laravel + Filament. - **Primary goals:** policy version control (diff/history/rollback), reliable backup & restore of Microsoft Intune configuration, admin-focused productivity features, auditability and least-privilege operations. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..1acf3daa --- /dev/null +++ b/docs/README.md @@ -0,0 +1,60 @@ +# TenantPilot Documentation Index + +> **Status:** Active +> **Last reviewed:** 2026-04-30 +> **Use for:** Navigating current documentation sources and understanding how to maintain them with low overhead +> **Do not use for:** Assuming any document is implementation truth without repo verification + +## Current Source of Truth + +- `docs/product/roadmap.md` - current product roadmap and prioritization context +- `docs/product/spec-candidates.md` - active spec candidate queue +- `docs/product/principles.md` - product and architecture principles +- `docs/strategy/product-vision.md` - long-term product vision +- `docs/strategy/domain-coverage.md` - domain and coverage strategy + +## Product Operations + +- `docs/product/discoveries.md` +- `docs/product/implementation-ledger.md` +- `docs/product/prompts/` +- `docs/product/standards/` + +## Technical Research + +- `docs/research/` + +## UI Standards + +- `docs/ui/` + +## Audits + +- `docs/audits/` + +## Security And Access References + +- `docs/PERMISSIONS.md` +- `docs/security/` + +## Historical Or Superseded Material + +- `docs/audits/archive/` +- audit-derived candidate documents that are marked `Historical`, `Superseded`, or `Needs Review` + +## Lightweight Maintenance Model + +- Keep only the current source-of-truth documents actively maintained. +- Update a document when the underlying roadmap, policy, or decision actually changes. +- Mark older material with status headers instead of rewriting it to feel current. +- Prefer archive or superseded markers over deletion. +- Verify implementation claims against repo code, specs, and tests. + +## Rules For Agents + +- Treat docs as guidance, not implementation truth. +- Verify implementation claims against repo code. +- Specs are not automatically implemented. +- Tests are not automatically executed. +- Historical audits may be outdated. +- Prefer current roadmap and spec-candidates for prioritization. \ No newline at end of file diff --git a/docs/audits/2026-03-15-audit-spec-candidates.md b/docs/audits/2026-03-15-audit-spec-candidates.md index ba437d81..e1eb1b73 100644 --- a/docs/audits/2026-03-15-audit-spec-candidates.md +++ b/docs/audits/2026-03-15-audit-spec-candidates.md @@ -1,5 +1,10 @@ # Audit-Derived Spec Candidates +> **Status:** Superseded +> **Last reviewed:** 2026-04-30 +> **Use for:** Historical context on audit-driven candidate clustering from March 2026 +> **Do not use for:** The current candidate queue; use `docs/product/spec-candidates.md` instead + **Date:** 2026-03-15 **Source audit:** [docs/audits/tenantpilot-architecture-audit-constitution.md](docs/audits/tenantpilot-architecture-audit-constitution.md) plus first-pass repo scan driven by [ .github/prompts/tenantpilot.audit.prompt.md ](.github/prompts/tenantpilot.audit.prompt.md) diff --git a/docs/audits/README.md b/docs/audits/README.md new file mode 100644 index 00000000..cad9ab9e --- /dev/null +++ b/docs/audits/README.md @@ -0,0 +1,10 @@ +# TenantPilot Audits + +> **Status:** Reference +> **Last reviewed:** 2026-04-30 +> **Use for:** Historical and current audit findings +> **Do not use for:** Current implementation truth without repo verification + +Audits are point-in-time assessments. They may be outdated after implementation. + +Use current source files, specs, tests, and product roadmap to verify whether a finding still applies. \ No newline at end of file diff --git a/docs/audits/enterprise-architecture-audit-2026-03-09.md b/docs/audits/enterprise-architecture-audit-2026-03-09.md index 64d26177..bf3d22fc 100644 --- a/docs/audits/enterprise-architecture-audit-2026-03-09.md +++ b/docs/audits/enterprise-architecture-audit-2026-03-09.md @@ -1,5 +1,10 @@ # Enterprise Architecture Audit — TenantPilot / TenantAtlas +> **Status:** Historical +> **Last reviewed:** 2026-04-30 +> **Use for:** Original architecture diagnosis and historical context for scope, panel, and RBAC design discussions +> **Do not use for:** Current architecture truth without repo verification + **Date:** 2026-03-09 **Auditor role:** Senior Enterprise SaaS Architect, UX/IA Auditor, Security/RBAC Reviewer **Stack:** Laravel 12, Filament v5, Livewire v4, PostgreSQL, Tailwind v4 diff --git a/docs/audits/legacy-orphaned-truth-audit.md b/docs/audits/legacy-orphaned-truth-audit.md index c4fe4d72..211da452 100644 --- a/docs/audits/legacy-orphaned-truth-audit.md +++ b/docs/audits/legacy-orphaned-truth-audit.md @@ -1,5 +1,10 @@ # Repo-wide Legacy / Orphaned Truth Audit +> **Status:** Needs Review +> **Last reviewed:** 2026-04-30 +> **Use for:** Cleanup investigations and historical context on legacy truth collisions in the repo +> **Do not use for:** Current implementation truth without repo verification + **Date**: 2026-03-16 **Scope**: Full codebase — models, migrations, enums, services, jobs, observers, Filament resources, policies, capabilities, badges, tests, factories **Method**: Systematic source-of-truth tracing across all layers diff --git a/docs/audits/semantic-clarity-spec-candidates.md b/docs/audits/semantic-clarity-spec-candidates.md index e5f3382a..4e6fe4f0 100644 --- a/docs/audits/semantic-clarity-spec-candidates.md +++ b/docs/audits/semantic-clarity-spec-candidates.md @@ -1,5 +1,10 @@ # Semantic Clarity — Spec Candidate Package +> **Status:** Superseded +> **Last reviewed:** 2026-04-30 +> **Use for:** Historical reasoning behind the semantic clarity cleanup program and its original packaging +> **Do not use for:** The current candidate queue, current spec numbering, or current implementation truth without repo verification + **Source audit:** `docs/audits/semantic-clarity-audit.md` **Date:** 2026-03-21 **Author role:** Senior Staff Engineer / Enterprise SaaS Product Architect diff --git a/docs/audits/tenantpilot-architecture-audit-constitution.md b/docs/audits/tenantpilot-architecture-audit-constitution.md index d3a1034b..e18d6d95 100644 --- a/docs/audits/tenantpilot-architecture-audit-constitution.md +++ b/docs/audits/tenantpilot-architecture-audit-constitution.md @@ -1,5 +1,10 @@ # TenantPilot Architecture Audit Constitution +> **Status:** Reference +> **Last reviewed:** 2026-04-30 +> **Use for:** Audit standards, architectural review criteria, and repo-level safety review framing +> **Do not use for:** Current implementation truth or roadmap priority without repo verification + ## Purpose This constitution defines the non-negotiable architecture, security, and workflow rules for TenantPilot / TenantAtlas. diff --git a/docs/product/discoveries.md b/docs/product/discoveries.md index 822a9814..b578bcb7 100644 --- a/docs/product/discoveries.md +++ b/docs/product/discoveries.md @@ -1,47 +1,25 @@ # Discoveries +> **Status:** Active +> **Last reviewed:** 2026-04-30 +> **Use for:** Parking implementation findings and follow-up ideas that are not yet part of the active roadmap or candidate queue +> **Do not use for:** Active priority order once an item is already tracked in the roadmap or spec-candidates +> > Things found during implementation that don't belong in the current spec. > Review weekly. Promote to [spec-candidates.md](spec-candidates.md) or discard. Items that are already tracked in [spec-candidates.md](spec-candidates.md) or [roadmap.md](roadmap.md) should not remain here. -**Last reviewed**: 2026-03-15 +**Last reviewed**: 2026-04-30 --- -## 2026-03-15 — Queued execution trust relies too much on dispatch-time authority +## 2026-04-30 — 2026-03-15 architecture hardening cluster moved out of discoveries - **Source**: architecture audit -- **Observation**: Queued jobs still rely too heavily on the actor, tenant, and authorization state captured at dispatch time. Execution-time scope continuity and reauthorization are not yet hardened as a canonical backend contract. -- **Category**: hardening -- **Priority**: high -- **Suggested follow-up**: Track in [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md) as Candidate A: queued execution reauthorization and scope continuity. - ---- - -## 2026-03-15 — Tenant-owned query canon remains too ad hoc -- **Source**: architecture audit -- **Observation**: Tenant isolation is broadly present, but many tenant-owned reads still depend on repeated local `tenant_id` filtering instead of a reusable canonical query path. This increases drift risk and weakens wrong-tenant regression discipline. -- **Category**: hardening -- **Priority**: high -- **Suggested follow-up**: Track in [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md) as Candidate B: tenant-owned query canon and wrong-tenant guards. - ---- - -## 2026-03-15 — Findings lifecycle truth is stronger in docs than in enforcement -- **Source**: architecture audit -- **Observation**: Findings workflow semantics are well-defined at spec level, but architectural enforcement still depends too much on service-path discipline. Direct or bypassing status mutations remain too plausible. -- **Category**: hardening -- **Priority**: high -- **Suggested follow-up**: Track in [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md) as Candidate C: findings workflow enforcement and audit backstop. - ---- - -## 2026-03-15 — Livewire trust-boundary hardening is still convention-driven -- **Source**: architecture audit -- **Observation**: Complex Livewire and Filament flows still expose too much ownership-relevant context in public component state. This is not a proven exploit in the repo today, but the hardening standard is not yet explicit or reusable. -- **Category**: hardening -- **Priority**: medium -- **Suggested follow-up**: Track in [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md) as Candidate D: Livewire context locking and trusted-state reduction. +- **Observation**: The queued execution, tenant-query-canon, findings-enforcement, and Livewire trust-boundary items from the 2026-03-15 audit are now tracked through promoted specs and the roadmap hardening lane. They no longer belong in `discoveries.md` as open findings. +- **Category**: documentation +- **Priority**: low +- **Suggested follow-up**: Use `roadmap.md` and `spec-candidates.md` for current hardening follow-through; keep `discoveries.md` for new findings that are not yet tracked elsewhere. --- diff --git a/docs/product/implementation-ledger.md b/docs/product/implementation-ledger.md index 8bec9042..095c7c69 100644 --- a/docs/product/implementation-ledger.md +++ b/docs/product/implementation-ledger.md @@ -1,5 +1,10 @@ # TenantPilot Implementation Ledger +> **Status:** Active +> **Last reviewed:** 2026-04-30 +> **Use for:** Repo-based implementation status and product-surface maturity assessment +> **Do not use for:** Roadmap priority, spec priority, or proof that tests were executed in the current branch + ## Purpose Dieses Dokument beschreibt den aktuellen repo-basierten Implementierungsstand von TenantPilot. Es ergaenzt `roadmap.md` und `spec-candidates.md`, ersetzt sie aber nicht. @@ -15,7 +20,7 @@ ## Purpose ## Current Product Position -TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls und inzwischen repo-real umgesetzten Customer-safe Review Consumption, Risk-Acceptance/Exception-Workflow, Findings-/Governance-Inboxen und einer DE/EN-Locale-Foundation. Die Repo-Wahrheit liegt damit klar ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Portfolio- und Commercial-Plattform ausgereift: Cross-Tenant-Workflows, Compare/Promotion, Billing-/Lifecycle-Reife und Private-AI-Governance bleiben unvollstaendig. Zusaetzlich zeigt der Repo-Stand weiterhin eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden. +TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls sowie einer repo-real umgesetzten ersten Customer-Review-Surface, Risk-Acceptance/Exception-Workflow, Findings-/Governance-Inboxen und einer DE/EN-Locale-Foundation. Die Repo-Wahrheit liegt damit klar ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Portfolio- und Commercial-Plattform ausgereift: Die Customer-Review-Surface ist noch eher eine operator-led customer delivery view im Admin-Kontext als eine voll produktisierte, kundensichere Governance-of-Record Consumption-Flache; dazu bleiben Cross-Tenant-Workflows, Compare/Promotion, Billing-/Lifecycle-Reife und Private-AI-Governance unvollstaendig. Zusaetzlich zeigt der Repo-Stand weiterhin eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden. ## Status Model @@ -41,7 +46,7 @@ ## Roadmap Coverage Summary | Roadmap Area | Status | Evidence Level | UI Ready | Tested | Sellable | Notes | |---|---|---:|---|---|---|---| | R1 Golden Master Governance | adopted | strong | yes | repo tests, not run | yes | Baselines, Drift, Findings und OperationRun-Truth sind breit im Produkt verankert. | -| R2 Tenant Reviews, Evidence & Control Foundation | adopted | strong | yes | repo tests, not run | yes | Reviews, Evidence, Review Packs, Customer Review Workspace und Control-/Exception-Layer greifen als reale Governance-Surface zusammen. | +| R2 Tenant Reviews, Evidence & Control Foundation | adopted | strong | yes | repo tests, not run | almost | Reviews, Evidence, Review Packs, Customer Review Workspace und Control-/Exception-Layer greifen als reale Governance-Surface zusammen, aber die Customer-Consumption-Productization bleibt unvollstaendig. | | Alert escalation + notification routing | implemented_verified | strong | partial | repo tests, not run | yes | Alert-Regeln, Dispatch, Cooldown und Quiet Hours sind real. | | Governance & Architecture Hardening | implemented_partial | strong | partial | repo tests, not run | foundation-only | Viele Hardening-Slices sind bereits im Code, die Lane bleibt aber aktiv. | | UI & Product Maturity Polish | implemented_partial | strong | partial | partial repo tests, not run | no | Empty States, Navigation, Localization und read-only Review-Polish sind real, aber kein geschlossenes Theme-Completion-Signal. | @@ -50,12 +55,12 @@ ## Roadmap Coverage Summary | R1.9 Platform Localization v1 | implemented_verified | strong | yes | repo tests, not run | foundation-only | Locale-Resolver, Override/Praeferenz, Workspace-Default, Fallback und lokalisierte Notifications sind repo-real. | | Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. | | R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. | -| R2 Completion: customer review, support, help | adopted | strong | yes | repo tests, not run | yes | Customer Review Workspace, Support Diagnostics/Requests und Help-Katalog sind repo-real. | +| R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Customer Review Workspace, Support Diagnostics/Requests und Help-Katalog sind repo-real, aber die Customer-Review-Consumption ist noch nicht voll productized. | | Findings Workflow v2 / Execution Layer | adopted | strong | yes | repo tests, not run | almost | Triage, Ownership, My Work, Intake, Governance Inbox, Exceptions und Alerts/Hygiene sind real; Cross-Tenant-Decisioning bleibt spaeter. | | Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. | | Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. | | Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. | -| Private AI Execution & Usage Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. | +| Private AI Execution Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. | | MSP Portfolio & Operations | implemented_partial | medium | partial | repo tests, not run | foundation-only | Portfolio-Triage ist da; Compare/Promotion und Decision Workboard fehlen. | | Human-in-the-Loop Autonomous Governance | planned | none | no | no | no | Kein repo-verifizierter Decision-Pack- oder Approval-Workflow jenseits des jetzigen Exception-/Review-Layers. | | Drift & Change Governance | implemented_partial | strong | yes | repo tests, not run | almost | Drift review, accepted-risk governance, exception validity und Governance-Inbox-Surfaces sind repo-real; portfolio-weite Eskalation bleibt offen. | @@ -75,7 +80,7 @@ ## Implemented Capabilities | Evidence snapshots | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Models/EvidenceSnapshot.php`; `app/Services/Evidence/EvidenceSnapshotService.php`; `tests/Feature/Evidence/*` | | Tenant reviews | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/TenantReview.php`; `app/Services/TenantReviews/TenantReviewService.php`; `tests/Feature/TenantReview/*` | | Review pack generation and export | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/ReviewPack.php`; `app/Services/ReviewPackService.php`; `tests/Feature/ReviewPack/*` | -| Customer review workspace | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`; `tests/Feature/Reviews/*`; `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` | +| Customer review workspace | implemented_partial | yes | yes | repo tests, not run | yes | almost | `app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`; `tests/Feature/Reviews/*`; `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` | | Alerts and notification routing | implemented_verified | yes | partial | repo tests, not run | yes | yes | `app/Services/Alerts/AlertDispatchService.php`; `tests/Feature/*Alert*` | | Provider health, onboarding readiness and required permissions | adopted | yes | yes | repo tests, not run | yes | almost | `app/Jobs/ProviderConnectionHealthCheckJob.php`; `app/Services/Onboarding/OnboardingLifecycleService.php`; `app/Filament/Pages/TenantRequiredPermissions.php` | | Permission posture reporting | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`; `tests/Feature/PermissionPosture/*` | @@ -110,7 +115,7 @@ ## Foundation-Only Capabilities ## Partial Capabilities -- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots, Review Packs und der Customer Review Workspace sind repo-real, aber portfolio-weite Consumption- und Sharing-Patterns bleiben offen. +- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots, Review Packs und der Customer Review Workspace sind repo-real, aber die Surface bleibt noch operator-led im Admin-Kontext; customer-safe wording, evidence summarization boundaries, audit-grade access semantics und calmer consumption states brauchen ein eigenes Productization-Follow-up. - Findings Workflow v2: Triage, Assignment, My Work, Intake, Governance Inbox, Exceptions und Notifications sind vorhanden; spaetere Cross-Tenant-Decisioning-Layer und Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten bleiben offen. - Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht. - MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen. @@ -119,7 +124,7 @@ ## Partial Capabilities ## Planned But Not Implemented -- Private AI Execution & Usage Governance Foundation +- Private AI Execution Governance Foundation - Human-in-the-Loop Autonomous Governance - Standardization & Policy Quality / Intune Linting - PSA / Ticketing Handoff @@ -132,9 +137,10 @@ ## Release Readiness | Release / Theme | Readiness | Notes | |---|---|---| | R1 Golden Master Governance | implemented | Die zentrale Governance- und Execution-Layer ist repo-verifiziert und breit adoptiert. | -| R2 Tenant Reviews & Evidence Packs | implemented | Reviews, Evidence Snapshots, Review Packs, Customer Review Workspace und Exception-/Accepted-Risk-Workflow sind repo-real; breitere Commercial-Polish-Themen bleiben separat. | +| R2 Tenant Reviews & Evidence Packs | implemented | Reviews, Evidence Snapshots, Review Packs, Customer Review Workspace und Exception-/Accepted-Risk-Workflow sind repo-real; die Customer-Review-Productization bleibt aber als sellability follow-up offen. | | R3 MSP Portfolio OS | foundation only | Portfolio-Triage und Governance-Surfaces sind da, aber Compare/Promotion und portfolio-weite Action-Layer fehlen. | -| Later Compliance Light | foundation only | Canonical Controls, Evidence und Exceptions existieren als Grundlage; ein Compliance-Produkt ist nicht repo-proven. | +| Compliance Evidence Mapping v1 | foundation only | Canonical Controls, Evidence, Stored Reports und Exceptions existieren als Grundlage; eine customer-safe Mapping-Layer ist nicht repo-proven. | +| Governance-as-a-Service Packaging v1 | foundation only | Review Packs, Exports, Evidence und Accepted-Risk-Truth sind repo-real; eine wiederholbare management-taugliche Governance-Verpackung ist nicht repo-proven. | ## Commercial Readiness @@ -142,14 +148,14 @@ ### Demo-ready - Baseline compare and drift walkthroughs - Review pack generation and export -- Customer-safe review workspace walkthroughs +- Customer review workspace walkthroughs with operator guidance - Provider health, onboarding readiness and required permissions - Support diagnostics - Permission posture and Entra admin roles reporting ### Almost sellable -- Review-driven governance workflow rund um Tenant Reviews, Customer Review Workspace, accepted risks und Review Packs +- Review-driven governance workflow rund um Tenant Reviews, Customer Review Workspace, accepted risks und Review Packs, aber noch nicht als vollstaendig productisierte customer-safe consumption experience - Baseline drift and restore governance - Findings workflow mit persönlicher Inbox, Intake, Governance Inbox und Exception-Handling - Alerting and run visibility for governance operations @@ -174,39 +180,46 @@ ### Foundation-only ### Not sellable yet - Cross-Tenant Compare and Promotion v1 +- Compliance Evidence Mapping v1 +- Governance-as-a-Service Packaging v1 - Private AI Execution Governance Foundation - External Support Desk / PSA Handoff -- Compliance Light product layer ## Open Gaps & Blockers | Gap | Type | Impact | Roadmap Area | Recommended Spec | |---|---|---|---|---| +| Customer review productization remains incomplete | Sellability blocker | The repo has a real read-only customer review surface, but it still sits too close to operator/admin semantics and does not yet enforce a fully customer-safe consumption contract for findings, evidence, accepted risks, and audit-grade access/download flows | R2 completion / Customer review | P0 Customer Review Workspace Productization v1 | | Decisioning still spans multiple repo-real inboxes | UX blocker | My Findings, Intake, Governance Inbox und Exception Queue sind real, aber Operators springen weiter zwischen mehreren Spezial-Surfaces und es gibt noch keinen portfolio-weiten Action-Layer | Findings Workflow / MSP Portfolio | P1 Governance Decision Surface Convergence | | Findings lifecycle backfill runtime surfaces remain productized | Cleanup blocker | Runbooks, commands, capabilities and tenant actions still expose a pre-production repair path that should not ship as product truth | Findings Workflow / Legacy Removal | P1 Remove Findings Lifecycle Backfill Runtime Surfaces | | Legacy `acknowledged` status compatibility still survives | Semantics blocker | Status helpers, filters, badges, capability aliases and tests keep non-canonical workflow semantics alive | Findings Workflow / RBAC | P1 Remove Legacy Acknowledged Finding Status Compatibility | | Creation-time finding invariants are implied but not explicitly protected | Integrity blocker | Future finding generators could regress into partial lifecycle writes and recreate the need for repair tooling | Findings Workflow / Data Integrity | P1 Enforce Creation-Time Finding Invariants | | Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 | | Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity | +| Compliance-oriented control mapping is not productized | Moat blocker | Canonical controls and evidence exist, but the product still lacks a bounded customer-safe layer that maps technical truth into control/readiness language | Compliance Evidence Mapping | P2 Compliance Evidence Mapping v1 | +| Review truth is not yet packaged as a repeatable MSP deliverable | Sellability blocker | Review packs and evidence are real, but recurring management-ready governance packaging still depends on manual interpretation and presentation | Governance-as-a-Service Packaging | P2 Governance-as-a-Service Packaging v1 | | Support requests do not hand off to an external desk | Commercialization blocker | Support operations still depend on manual follow-through outside the product | R2 completion / Support | P2 External Support Desk / PSA Handoff | -| AI governance foundation is absent | Architecture blocker | Future AI features would risk trust and policy drift if added directly | Private AI Execution & Usage Governance | P3 Private AI Execution Governance Foundation | +| AI governance foundation is absent | Architecture blocker | Future AI features would risk trust and policy drift if added directly | Private AI Execution Governance | P3 Private AI Execution Governance Foundation | | Roadmap understates current repo truth | Architecture blocker | Prioritization can drift because strategy docs still lag neuere Review-, Findings- und Localization-Surfaces | Product planning / roadmap maintenance | none - docs alignment | | Test files were not executed for this ledger update | Testing blocker | This document relies on code plus test presence, not live runtime validation | all areas | none - run targeted suites | ## Recommended Next Specs +- `P0 Customer Review Workspace Productization v1`: turns the existing admin-plane handoff into a more explicit customer-safe review consumption contract with calmer wording, progressive disclosure, explicit access states, and auditable download/view semantics. - `P1 Governance Decision Surface Convergence`: verbindet My Findings, Intake, Governance Inbox, Customer Review Workspace und Exception Queue zu weniger Operator-Journeys und bereitet die Portfolio-Ebene vor. - `P1 Remove Findings Lifecycle Backfill Runtime Surfaces`: removes visible pre-production repair tooling from runbooks, commands, actions, capabilities and deploy/runtime hooks. - `P1 Remove Legacy Acknowledged Finding Status Compatibility`: collapses findings workflow semantics onto the canonical `triaged` model and removes stale RBAC/query aliases. - `P1 Enforce Creation-Time Finding Invariants`: proves that new findings are lifecycle-ready at write time so no repair backfill has to return later. - `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action. - `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle. +- `P2 Compliance Evidence Mapping v1`: should start as one bounded versioned overlay that maps existing technical truth into one customer-safe control/readiness view and one reuse path into review or export surfaces. +- `P2 Governance-as-a-Service Packaging v1`: should start as one on-demand management-ready governance package built from existing review-pack, evidence, and accepted-risk truth rather than a broad recurring reporting suite. - `P2 External Support Desk / PSA Handoff`: extends support requests beyond internal persistence. - `P3 Private AI Execution Governance Foundation`: should exist before feature-level AI adoption, not after it. ## Roadmap Drift Notes -- `roadmap.md` understates current R2 completion. Customer Review Workspace, published review handoff, review-pack downloads und der Finding-Exception-/Risk-Acceptance-Workflow sind bereits repo-real. +- `roadmap.md` understates current R2 implementation depth, but the ledger had overstated sellability. Customer Review Workspace, published review handoff, review-pack downloads und der Finding-Exception-/Risk-Acceptance-Workflow sind repo-real; the remaining gap is customer-safe productization, not review-foundation absence. - `roadmap.md` understates findings workflow maturity. My Findings, Intake, Governance Inbox und Exception Queue existieren bereits im Repo. - `roadmap.md` understates localization maturity. Locale resolution order, Workspace-Default, User-Praeferenz, lokalisierte Notifications und Fallback-Tests sind implementiert. - `roadmap.md` understates the current R2 control foundation. Canonical controls, stored reports, permission posture and Entra admin roles are already repo-real, not just near-term ideas. @@ -214,7 +227,7 @@ ## Roadmap Drift Notes - `roadmap.md` understates operational maturity. Product telemetry, customer health and operational controls are already implemented and wired into the system panel. - `roadmap.md` understates commercial foundations. A workspace entitlement resolver, plan profiles and enforcement points already exist, even though full billing-state maturity does not. - The roadmap is now better at describing still-missing portfolio- und commercial-Layer than the current state of review/findings/localization implementation. Cross-Tenant Compare and Promotion, full billing-state maturity, external PSA handoff and AI Governance still look genuinely unimplemented. -- The main drift pattern is underestimation, not overestimation. Customer-facing review consumption is no longer the clearest missing piece; portfolio action and commercial lifecycle are. +- The main drift pattern is still underestimation, but customer-review sellability now needs a more precise reading: the missing piece is no longer basic review read-only access, but the final customer-safe productization layer over an already real surface. ## Evidence Sources diff --git a/docs/product/operator-semantic-taxonomy.md b/docs/product/operator-semantic-taxonomy.md index f6a4adef..bf4e4bc6 100644 --- a/docs/product/operator-semantic-taxonomy.md +++ b/docs/product/operator-semantic-taxonomy.md @@ -1,5 +1,10 @@ # Operator Semantic Taxonomy +> **Status:** Active +> **Last reviewed:** 2026-04-30 +> **Use for:** Canonical operator-facing vocabulary for lifecycle, outcome, evidence, and actionability states +> **Do not use for:** Inventing local synonyms or assuming every product surface already fully conforms without repo verification +> > Canonical operator-facing state reference for the first implementation slice. > Downstream specs and badge mappings must reuse this vocabulary instead of inventing local synonyms. diff --git a/docs/product/principles.md b/docs/product/principles.md index 89baaba1..d3bcbbab 100644 --- a/docs/product/principles.md +++ b/docs/product/principles.md @@ -1,5 +1,10 @@ # Product Principles +> **Status:** Active +> **Last reviewed:** 2026-04-30 +> **Use for:** Stable product and architecture principles that should shape specs, UX, and implementation choices +> **Do not use for:** Assuming every principle is already enforced everywhere in code without repo verification +> > Permanent product principles that govern every spec, every UI decision, and every architectural choice. > New specs must align with these. If a principle needs to change, update this file first. diff --git a/docs/product/prompts/README.md b/docs/product/prompts/README.md new file mode 100644 index 00000000..75871bfd --- /dev/null +++ b/docs/product/prompts/README.md @@ -0,0 +1,11 @@ +# Product & Roadmap Prompts + +> **Status:** Active +> **Last reviewed:** 2026-04-30 +> **Use for:** Reusable roadmap, productization, and portfolio audit prompts +> **Do not use for:** Direct implementation without converting outputs into specs or verified planning artifacts + +This folder contains reusable prompts for roadmap analysis, productization audits, and product strategy reviews. + +Prompts are not specs. +Prompts are tools to generate, validate, or refine roadmap and spec-candidate decisions. \ No newline at end of file diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index 6518dff0..f26e066c 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -1,9 +1,16 @@ # Product Roadmap +> **Status:** Active +> **Last reviewed:** 2026-04-30 +> **Use for:** Current product roadmap, release themes, and prioritization context +> **Do not use for:** Implementation truth, spec completion status, or delivery guarantees without repo verification +> > Strategic thematic blocks and release trajectory. > This is the "big picture" — not individual specs. +> +> Queue boundary: the active candidate queue lives in `spec-candidates.md`; older audit-derived candidate packages are historical inputs only. -**Last updated**: 2026-04-25 +**Last updated**: 2026-04-30 --- @@ -16,6 +23,26 @@ ## Release History | **R2 "Tenant Reviews, Evidence & Control Foundation"** | Evidence packs, stored reports, canonical control catalog, permission posture, alerts | **Partial** | | **R2 cont.** | Alert escalation + notification routing | **Done** | +## Current Productization & Moat Priorities + +This is the repo-based prioritization overlay for the next sellable lanes. The bottleneck is no longer raw backend truth alone. The next roadmap slices should make existing governance foundations customer-safe, decision-centered, auditable, and MSP-sellable before opening more backend-only islands. + +| Order | Theme | Repo truth | Product posture | Why now | Candidate posture | +|---|---|---|---|---|---| +| 1 | Customer Review Workspace Productization v1 | Reviews, Evidence Snapshots, Review Packs, Customer Review Workspace, and accepted-risk foundations are repo-real | fast sellable | clearest sellability blocker between current repo truth and a customer-safe governance-of-record surface | active P0 candidate | +| 2 | Risk Acceptance & Accountability productization | Exception / risk-acceptance workflow is repo-verified, but customer-safe accountability presentation is not fully productized | fast sellable | strong MSP and German midmarket moat around documented decisions, expiry, reviewability, and audit trail | fold into Customer Review Workspace Productization and review/reporting follow-through, not a new greenfield foundation | +| 3 | Governance Decision Surface Convergence | Governance Inbox, My Findings, Intake, and Exception Queue are repo-real, but convergence is not | almost | reduces admin-tool sprawl and turns multiple queue surfaces into calmer decision work | active P1 candidate | +| 4 | Compliance Evidence Mapping v1 | Canonical controls, evidence, stored reports, reviews, and findings foundations are repo-real; customer-safe compliance mapping is not | foundation-only | strong governance moat for compliance-oriented MSP and Mittelstand reviews without certification claims | active P2 candidate | +| 5 | Governance-as-a-Service Packaging v1 | Review packs, exports, evidence, and accepted-risk foundations are repo-real; recurring executive/MSP packaging is not | foundation-only | turns governance truth into a repeatable MSP deliverable instead of one-off manual reporting | active P2 candidate | +| 6 | Cross-Tenant Compare & Promotion v1 | Portfolio triage exists; compare and promotion are not repo-proven | not implemented | strongest MSP multiplier after customer-safe review and decision workflows are calmer | active P1 candidate | +| 7 | Private AI Execution Governance Foundation | Spec 248 exists, but no repo-real governed AI execution layer is proven | only spec / not implemented | strategic moat later, but not ahead of current productization and portfolio-action gaps | keep as later strategic lane, not near-term blocker | + +Explicit anti-sprawl boundaries for this priority set: + +- Do not reopen risk acceptance as a broad new foundation theme; reuse the existing exception/risk-acceptance workflow and productize its customer-safe accountability trail. +- Do not reopen private AI as a fresh roadmap idea; the foundation already exists at spec level and should remain behind current customer-facing and MSP-facing sellability gaps. +- Do not prioritize Tenant Trust Score / public governance profile, insurance connectors, Copilot shadow-IT governance, local-first/on-prem proxy, or a standalone Betriebsrat mode before customer-safe review consumption, decision convergence, compliance mapping, governance packaging, and compare/promotion are materially clearer. + --- ## Active / Near-term @@ -51,6 +78,8 @@ ### R1.9 Platform Localization v1 (DE/EN) UI-Sprache umschaltbar (`de`, `en`) mit sauberem Locale-Foundation-Layer. Goal: Konsistente, durchgängige Lokalisierung aller Governance-Oberflächen — ohne Brüche in Export, Audit oder Maschinenformaten. +Repo reality: Die Locale-Foundation ist bereits repo-real. Der verbleibende Gap ist kein greenfield Localization-Foundation-Spec mehr, sondern Surface-Adoption, Copy-/Glossary-Vervollständigung und Regression-Hardening auf customer- und governance-nahen Oberflächen. + - Locale-Priorität: expliziter Override → User Preference → Workspace Default → System Default - Workspace Default Language für neue Nutzer, User kann persönliche Sprache überschreiben - Core-Surfaces zuerst: Navigation, Dashboard, Tenant Views, Findings, Baseline Compare, Risk Exceptions, Alerts, Operations, Audit-nahe Grundtexte @@ -64,7 +93,7 @@ ### R1.9 Platform Localization v1 (DE/EN) - Search/Sort/Filter auf kritischen Listen für locale-sensitives Verhalten prüfen - QA/Foundation: Missing-Key Detection, Locale Regression Tests, Pseudolocalization Smoke Tests für kritische Flows -**Active specs**: — (not yet specced) +**Queue status**: no standalone active candidate right now; remaining localization work should be folded into customer-facing productization and UI-maturity follow-through unless a narrower repo-real gap emerges. ### Product Scalability & Self-Service Foundation Self-service and supportability foundation that keeps TenantPilot operable as a low-headcount, AI-assisted SaaS instead of drifting into manual onboarding, manual support, and founder-dependent customer operations. @@ -110,10 +139,10 @@ ### R1.x Foundation Hardening — Governance Platform Anti-Drift ### R2 Completion — Evidence & Exception Workflows - Review pack export (Spec 109 — done) -- Exception/risk-acceptance workflow for Findings → Spec 154 (draft) +- Exception/risk-acceptance workflow for Findings → Spec 154 (repo-real foundation; the next product gap is accountability-trail productization in customer-safe review, expiry/re-review visibility, and management-ready reporting) - Formal evidence/review-pack entity foundation → Spec 153 (evidence snapshots, draft) + Spec 155 (tenant review layer / review packs, draft) - Workspace-level PII override for review packs → deferred from 109 -- Customer Review Workspace / Read-only View v1 → sharpen customer-facing review consumption: baseline status, latest reviews, findings, accepted risks, evidence/review-pack downloads, customer-safe redaction, and no admin/remediation actions +- Customer Review Workspace Productization v1 → sharpen customer-facing review consumption: baseline status, latest reviews, findings, accepted risks, evidence/review-pack downloads, customer-safe redaction, calmer access states, and no admin/remediation actions - Support Diagnostic Pack → connect tenant/review/finding/report/operation contexts into a reusable support bundle before support demand scales - In-App Support Request with Context → attach the relevant diagnostic pack and ticket reference to support workflows without creating a separate support data model - Product Knowledge & Contextual Help → reuse canonical glossary, outcome/reason semantics, and report/finding terminology as the product-help source layer @@ -127,10 +156,24 @@ ### Findings Workflow v2 / Execution Layer - Reuse the existing alerting foundation for assignment, reopen, due-soon, and overdue notification flows - Keep comments, external ticket handoff, and cross-tenant workboards as later slices instead of forcing them into the first workflow iteration -### Policy Lifecycle / Ghost Policies -Soft delete detection, automatic restore, "Deleted" badge, restore from backup. -Draft exists (Spec 900). Needs spec refresh and prioritization. -**Risk**: Ghost policies create confusion for backup item references. +### Workspace, Tenant & Managed Object Lifecycle Governance +Strategic lifecycle taxonomy for workspaces, tenants, managed provider objects, evidence, backups, restoreability, export, retention, and purge. +**Goal**: Prevent local lifecycle fixes such as “Ghost Policies” from introducing inconsistent deletion semantics before TenantPilot has one enterprise-grade lifecycle model. + +TenantPilot must distinguish at least these lifecycle dimensions: + +- Local record lifecycle: active, archived, locally removed, purge scheduled, purged +- Provider presence lifecycle: present, missing from provider, provider deleted, reappeared +- Operator suppression lifecycle: visible, ignored / suppressed, restored to visibility +- Commercial / workspace lifecycle: trial, active, grace, suspended read-only, closed +- Retention / compliance lifecycle: retained, export requested, deletion requested, deletion scheduled, legal hold / retention hold, purge due, purged +- Restoreability lifecycle: restorable, metadata only, blocked by dependency, not restorable, expired by retention + +**Roadmap posture**: Strategic P2 enterprise-trust candidate, not immediate implementation. This should not block Customer Review Workspace Productization, Governance Decision Surface Convergence, or Cross-Tenant Compare & Promotion. + +**Important boundary**: Do not implement a narrow policy-only ghost lifecycle patch, Laravel `SoftDeletes` rollout, workspace deletion flow, tenant deletion flow, purge engine, or retention framework before this lifecycle taxonomy is agreed. + +**Spec candidate**: `Workspace, Tenant & Managed Object Lifecycle Governance v1` in `docs/product/spec-candidates.md`. ### Platform Operations Maturity - CSV export for filtered run metadata (deferred from Spec 114) @@ -166,7 +209,7 @@ ### Additional Solo-Founder Scale Guardrails - Vendor Questionnaire Answer Bank: reusable security/procurement answers aligned with the Security Trust Pack, product data model, Microsoft permissions, hosting, AI usage, subprocessors, retention, backup, deletion, and incident handling - Product Intake & No-Customization Governance: feature-request intake, roadmap-fit classification, no-custom-work policy, customer exception handling, productization rules, and a clear path from request → candidate → spec → release or rejection - Support Severity Matrix & Runbooks: P1–P4 definitions, incident vs support vs bug vs feature request distinction, response expectations by plan, escalation rules, known-issue handling, and internal support runbooks -- Data Retention, Export & Deletion Self-Service: customer-facing and operator-facing flows for export, archive, deletion request handling, trial data expiry, workspace deactivation, and evidence/report retention visibility +- Data Retention, Export & Deletion Self-Service: customer-facing and operator-facing flows for export, archive, deletion request handling, trial data expiry, workspace deactivation, and evidence/report retention visibility; depends on the shared Workspace, Tenant & Managed Object Lifecycle Governance taxonomy before destructive or retention-sensitive flows are implemented - Business Continuity / Founder Backup Plan: access documentation, secret management, emergency contacts, deployment and restore runbooks, incident templates, DNS/domain/hosting ownership, billing access, and vacation/sickness fallback **Active specs**: — (not yet specced; guardrail track, only product-impacting items should become specs) @@ -182,12 +225,13 @@ ### Product Usage, Customer Health & Operational Controls **Depends on**: Self-Service Tenant Onboarding & Connection Readiness, Support Diagnostic Pack, Plans / Entitlements & Billing Readiness, ProviderConnection health, OperationRun truth, Findings workflow, StoredReports / EvidenceItems, and audit log foundation. **Scope direction**: Start with privacy-aware product telemetry, derived customer/workspace health indicators, and a minimal operational controls registry. Avoid building a full analytics platform, CRM, or customer-success suite in the first slice. -### Private AI Execution & Usage Governance Foundation +### Private AI Execution Governance Foundation Strategic AI platform foundation for using AI inside TenantPilot without hard-coding public cloud AI calls, leaking tenant data, losing cost control, or forcing later rewrites. **Goal**: Make AI local/private-first, explicitly governed, budgeted, cacheable, auditable, and human-approved. External public AI providers are disabled by default and only usable through workspace-level opt-in, data classification, redaction, usage limits, and approval gates. **Why it matters**: TenantPilot sells governance, compliance readiness, evidence, and tenant trust. AI cannot be bolted on through direct feature-level API calls. The platform needs a reusable execution boundary so support summaries, finding explanations, review packs, decision packs, and customer communications can use AI later without rebuilding privacy, cost, provider, approval, and audit controls each time. **Depends on**: Product Knowledge & Contextual Help, Support Diagnostic Pack, Decision Pack Contract & Approval Workflow, Product Usage & Adoption Telemetry, Plans / Entitlements & Billing Readiness, Operational Controls & Feature Flags, Security Trust Pack Light, audit log foundation, and workspace/RBAC isolation. **Scope direction**: Build the foundation before broad AI features: AI use case registry, AI provider registry, workspace AI policy, AI data classification, AI context builders, AI policy gate, AI budget gate, AI result store/cache, AI usage ledger, and AI audit trail. Start with local/private and customer-hosted model compatibility; keep external provider support optional and explicit. +**Priority note**: This remains strategic, but it should stay behind current customer-review productization, decision convergence, compliance mapping, governance packaging, and compare/promotion gaps. **Core principles**: - AI is never called directly from feature code; every AI action goes through governed use cases, policy gates, budget gates, context builders, provider adapters, cache/result storage, and audit trails @@ -212,7 +256,7 @@ ### AI-Assisted Customer Operations AI-assisted customer operations layer for support, reviews, summaries, release communication, and customer-facing explanations, explicitly bounded by private AI execution policy, human approval, and product auditability. **Goal**: Use AI to prepare, summarize, classify, and draft customer operations work while keeping tenant-changing actions, customer commitments, legal statements, and external communications under human approval. **Why it matters**: TenantPilot can stay lean only if support, customer reviews, release communication, and diagnostics are structured enough for AI assistance without becoming ungoverned automation or uncontrolled public-model data processing. -**Depends on**: Private AI Execution & Usage Governance Foundation, Product Knowledge & Contextual Help, Support Diagnostic Pack, In-App Support Request, StoredReports / EvidenceItems, Findings workflow maturity, release-note discipline, and company support-desk structure. +**Depends on**: Private AI Execution Governance Foundation, Product Knowledge & Contextual Help, Support Diagnostic Pack, In-App Support Request, StoredReports / EvidenceItems, Findings workflow maturity, release-note discipline, and company support-desk structure. **Scope direction**: Start with AI-generated support summaries, finding explanations, tenant review summaries, diagnostic summaries, release-note drafts, and support-response drafts. Prefer local/private execution for tenant/customer data. Avoid autonomous tenant remediation, automatic risk acceptance, automatic legal commitments, or customer-facing messages without review. ### Decision-Based Operating Foundations @@ -221,12 +265,14 @@ ### Decision-Based Operating Foundations **Why it matters**: Governance inboxes, actionable alerts, and later autonomous-governance features will fail if they land on top of detail-heavy, entity-first navigation. This is the UX/product prerequisite layer for the later MSP Portfolio OS direction. **Depends on**: Current constitution and action-surface hardening, operator-truth work, existing navigation/context specs. **Scope direction**: First the constitution/rule delta, then a surface / IA classification of current product surfaces, then bounded retrofits that demote detail-first flows behind progressive disclosure instead of creating more top-level pages. +**Concrete next slice**: `Governance Decision Surface Convergence` is the repo-based follow-up. Do not reopen the first Governance Inbox as a greenfield concept. ### MSP Portfolio & Operations (Multi-Tenant) Multi-tenant health dashboard, SLA/compliance reports (PDF), cross-tenant troubleshooting center. **Source**: 0800-future-features brainstorming, identified as highest priority pillar. **Prerequisites**: Decision-Based Operating Foundations, Cross-tenant compare (Spec 043 — draft only). **Later expansion**: portfolio operations should eventually include a cross-tenant findings workboard once tenant-level inbox and intake flows are stable. +**Concrete next slice**: `Cross-Tenant Compare & Promotion v1` is the repo-based move from portfolio visibility toward portfolio action. ### Human-in-the-Loop Autonomous Governance (Microsoft-first, Provider-extensible Decision-Based Operating) Continuous detection, triage, decision drafting, approval-driven execution, and closed-loop evidence for governance actions across the Microsoft-first workspace portfolio, while keeping the decision model provider-extensible for later non-Microsoft domains. @@ -260,12 +306,12 @@ ### PSA / Ticketing Handoff Outbound handoff from findings into external service-desk or PSA systems with visible `ticket_ref` linkage and auditable "ticket created/linked" events. **Scope direction**: start with one-way handoff and internal visibility, not full bidirectional sync or full ITSM modeling. -### Compliance Readiness & Executive Review Packs -On-demand review packs that combine governance findings, accepted risks, evidence, baseline/drift posture, canonical control coverage, and key security signals into one coherent deliverable. CIS-aligned baseline libraries plus NIS2-/BSI-oriented readiness views depend on the Canonical Control Catalog and Evidence-to-Control mapping and remain explicitly without certification claims. Executive / CISO / customer-facing report surfaces alongside operator-facing detail views. Exportable auditor-ready and management-ready outputs. -**Goal**: Make TenantPilot sellable as an MSP-facing governance and review platform for German midmarket and compliance-oriented customers who want structured tenant reviews and management-ready outputs on demand. -**Why it matters**: Turns existing governance data into a clear customer-facing value proposition. Strengthens MSP sales story beyond backup and restore. Creates a repeatable "review on demand" workflow for quarterly reviews, security health checks, and audit preparation. -**Depends on**: Canonical Control Catalog Foundation, Evidence-to-Control mapping, StoredReports / EvidenceItems foundation, Tenant Review runs, Customer Review Workspace / Read-only View, 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. +### Compliance Evidence Mapping v1 +Versioned mapping layer that connects technical findings, accepted risks, evidence, and review outcomes to customer-safe control and readiness views without certification claims. +**Goal**: Translate existing governance truth into control- and audit-ready language for German midmarket and compliance-oriented customers while keeping technical findings clearly separate from regulatory interpretation. +**Why it matters**: Canonical controls and evidence are already repo-real foundations. The missing value is not another control catalog, but a customer-safe mapping layer that explains why a finding matters, what evidence exists, and which control or readiness statement it supports. +**Depends on**: Canonical Control Catalog Foundation, Evidence-to-Control mapping, StoredReports / EvidenceItems foundation, Tenant Review runs, Customer Review Workspace Productization, Findings + Risk Acceptance workflow, and export maturity. +**Scope direction**: Start with versioned control interpretations plus one bounded overlay layer. Avoid certification promises, legal guarantees, or framework-specific deep branching inside the platform core. **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. **Layering**: @@ -280,6 +326,13 @@ ### Compliance Readiness & Executive Review Packs - 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 +### Governance-as-a-Service Packaging v1 +Recurring governance deliverables for MSPs and customer stakeholders built on review packs, accepted risks, evidence, and control mapping. +**Goal**: Let MSPs deliver monthly or quarterly governance packages without manual screenshot decks, Excel exports, or ad-hoc PowerPoint work. +**Why it matters**: This is the commercial layer that turns already-strong review, evidence, and accepted-risk foundations into a repeatable MSP revenue surface. TenantPilot should sell not only truth capture, but calm customer-safe governance reporting. +**Depends on**: Customer Review Workspace Productization, Compliance Evidence Mapping v1, Review Packs, StoredReports / EvidenceItems, Findings + Risk Acceptance workflow, export pipeline maturity, localization, and entitlements. +**Scope direction**: Start with executive summary, top findings, accepted risks, open decisions, evidence links, management-ready review-pack export, and bounded MSP branding. Avoid CRM/newsletter tooling, PSA replacement, or raw operator-data dumps as the default output. + ### Entra Role Governance Expand TenantPilot's governance coverage into Microsoft Entra role definitions and assignments as a first-class identity administration surface. **What it means**: Inventory and visibility for built-in and custom role definitions. Visibility into role assignments and governance-relevant changes. Review-ready representation of identity administration posture. @@ -331,15 +384,15 @@ ## Infrastructure & Platform Debt | Item | Risk | Status | |------|------|--------| | No explicit company automation roadmap linkage | Risk that sales, support, billing, legal, and customer communication become founder-only manual work | Covered by Solo-Founder SaaS Automation & Operating Readiness | -| No product-level entitlement foundation yet | Later pricing, trial, retention, export, user, and tenant limits may require invasive retrofits | Covered by Product Scalability & Self-Service Foundation | +| No shared lifecycle taxonomy for workspace, tenant, managed-object, retention, export, purge, and restoreability states | Local fixes such as ghost-policy handling, workspace deactivation, tenant removal, retention, or purge can create inconsistent deletion semantics and audit gaps | Covered by Workspace, Tenant & Managed Object Lifecycle Governance candidate | | No structured support diagnostic bundle yet | Support cases require manual context gathering across tenants, runs, findings, providers, and reports | Covered by Product Scalability & Self-Service Foundation | | No formal security trust pack yet | Enterprise sales and customer security reviews require repeated manual explanations | Covered by Solo-Founder SaaS Automation & Operating Readiness | | No product usage/adoption telemetry yet | Founder cannot see onboarding drop-off, feature adoption, trial health, or support-triggering surfaces without manual investigation | Covered by Additional Solo-Founder Scale Guardrails | | No customer health score yet | Churn, inactive customers, stale reviews, unhealthy provider connections, and unresolved high-risk findings may be noticed too late | Covered by Additional Solo-Founder Scale Guardrails | | No explicit operational controls / feature flags lane | Incidents or risky features may require code changes or manual database intervention instead of safe operator controls | Covered by Additional Solo-Founder Scale Guardrails | -| No private AI execution foundation yet | Future AI features may call model providers directly, leak tenant context, become hard to audit, or require rewrites to support local/private models | Covered by Private AI Execution & Usage Governance Foundation | -| No AI usage budgeting / cost governance yet | AI-assisted summaries, decision packs, reviews, and support workflows may create uncontrolled compute/API costs and queue pressure | Covered by Private AI Execution & Usage Governance Foundation | -| No AI data classification / context-builder boundary yet | Raw provider payloads, personal data, or customer-confidential tenant context could be over-shared with models instead of sanitized purpose-specific context | Covered by Private AI Execution & Usage Governance Foundation | +| No private AI execution foundation yet | Future AI features may call model providers directly, leak tenant context, become hard to audit, or require rewrites to support local/private models | Covered by Private AI Execution Governance Foundation | +| No AI usage budgeting / cost governance yet | AI-assisted summaries, decision packs, reviews, and support workflows may create uncontrolled compute/API costs and queue pressure | Covered by Private AI Execution Governance Foundation | +| No AI data classification / context-builder boundary yet | Raw provider payloads, personal data, or customer-confidential tenant context could be over-shared with models instead of sanitized purpose-specific context | Covered by Private AI Execution Governance Foundation | | No no-customization governance yet | Customer-specific requests can silently turn the product into consulting work and create hidden maintenance obligations | Covered by Additional Solo-Founder Scale Guardrails | | No business-continuity / founder-backup plan yet | Solo-founder operations create continuity risk for incidents, illness, vacation, access recovery, and customer trust | Covered by Additional Solo-Founder Scale Guardrails | | No `.env.example` in repo | Onboarding friction | Open | @@ -356,7 +409,7 @@ ## Priority Ranking (from Product Brainstorming) 1. Product Scalability & Self-Service Foundation 2. Product Usage, Customer Health & Operational Controls -3. Private AI Execution & Usage Governance Foundation +3. Private AI Execution Governance Foundation 4. Decision-Based Operating / Governance Inbox 5. MSP Portfolio + Alerting 6. Drift + Approval Workflows @@ -373,6 +426,7 @@ ## How to use this file - **Big product and operating themes** live here. - **Concrete spec candidates** → see [spec-candidates.md](spec-candidates.md) +- **Lifecycle / deletion / retention work must be taxonomy-first**: do not promote narrow ghost-policy, workspace deletion, tenant deletion, purge, or retention specs until the shared Workspace, Tenant & Managed Object Lifecycle Governance candidate defines the platform semantics. - **Company automation / solo-founder operating items** live here as strategic tracks first; only product-impacting or repeatable engineering work should become spec candidates. - **Solo-founder guardrails** should remain visible even when they are not immediate product specs, because they define what must become measurable, controllable, delegable, or documented before customer volume grows. - **Governance positioning is Microsoft-first, provider-extensible**: roadmap language should keep the initial product scope focused on Microsoft tenant governance while avoiding unnecessary Microsoft-only coupling in platform-level abstractions. diff --git a/docs/product/spec-candidates.md b/docs/product/spec-candidates.md index a7fd663e..a07e3dcd 100644 --- a/docs/product/spec-candidates.md +++ b/docs/product/spec-candidates.md @@ -1,9 +1,14 @@ # Spec Candidates +> **Status:** Active +> **Last reviewed:** 2026-04-30 +> **Use for:** The active repo-based queue of spec candidates that may still need new or refreshed specs +> **Do not use for:** Proof that a candidate is already specced, implemented, or prioritized above the roadmap without repo verification +> > Repo-based next-spec queue for TenantPilot. > This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs. -> **Last reviewed**: 2026-04-28 +> **Last reviewed**: 2026-04-30 > **Basis**: `implementation-ledger.md`, `roadmap.md`, current `specs/` truth --- @@ -19,70 +24,115 @@ ## Candidate Rules - P3 is for later platform ambitions after current release blockers close. - Existing candidate history is preserved through `Promoted to Spec`, `Deferred`, and `Superseded / Removed` notes rather than silent deletion. +## Current Source-Of-Truth Boundary + +- This file is the active candidate queue. +- `roadmap.md` provides strategic themes and release framing, not the canonical candidate queue. +- `discoveries.md` is a staging area for findings that may later be promoted here. +- `implementation-ledger.md` is maturity evidence, not a prioritization queue. +- Audit-derived candidate packages under `docs/audits/` are historical inputs only unless they are explicitly promoted into this file. + ## Active Candidate Queue ### P0 — Release Blockers -### Customer Review Workspace v1 +### Customer Review Workspace Productization v1 - **Priority**: P0 -- **Why this stays active**: The repo already has strong internal review foundations: tenant reviews, evidence snapshots, review packs, redaction paths, entitlements, audit, and RBAC-aware surfaces. What is still missing is the customer-safe read-only consumption layer that turns those internal assets into a clearly sellable review product. -- **Roadmap relationship**: R2 completion / customer-facing review consumption. +- **Candidate type**: Productization / customer-safe consumption. +- **Why this stays active**: The repo already has strong review foundations plus a repo-real `CustomerReviewWorkspace` page from Spec 249. What remains open is productization: the current surface still behaves more like an operator-led customer delivery view inside the admin plane than a fully customer-safe governance-of-record consumption experience. +- **Roadmap relationship**: R2 completion / customer-facing review consumption and sellability polish. +- **Existing implementation context**: Spec 249 (`customer-review-workspace`) delivered the first read-only workspace handoff. This candidate is the bounded follow-up that hardens the existing surface into a clearer customer-safe product contract instead of reopening review foundations from scratch. +- **Goal**: Turn the existing customer review surface into a customer-safe, read-only review consumption experience for customer reviewers, customer admins, and auditors that answers what was reviewed, what is critical, what was accepted, what evidence exists, and what the next sensible step is. - **Dependencies**: - `TenantReview` - - `EvidenceSnapshot` - `ReviewPack` + - `EvidenceSnapshot` + - `Finding` + - accepted risks / exceptions workflow - existing redaction behavior + - stored reports and canonical control catalog foundations - workspace entitlements - - tenant/workspace RBAC and audit foundations + - tenant/workspace RBAC, audit, localization, and workspace-isolation foundations - **Scope**: - - customer-safe read-only workspace or view for latest review state - - latest findings and accepted risks in customer-safe form - - review-pack download surface with existing redaction rules - - explicit absence of admin or remediation actions - - clear authorization boundaries for customer and read-only viewers + - productize the existing customer review workspace into a clearer customer-safe read-only review consumption surface + - keep the primary surface centered on customer review workspace, review detail, findings summary, accepted-risk summary, evidence summary, and review-pack download areas + - visible findings semantics with severity, status, reason, impact, and recommendation in customer-safe language + - accepted risks / exceptions shown as understandable governance decisions rather than internal workflow residue, including decision reason, accountable person or role, decision timing, expiry / re-review state, evidence linkage, and review context in customer-safe language + - evidence snapshots shown as narrative summaries and proof pointers, not raw JSON or provider payloads + - review-pack download area with existing redaction and entitlement rules + - clearer control / baseline context and next-step guidance for customer reviewers, customer admins, and auditors + - explicit audit events for workspace access, review detail access, evidence summary access, and pack downloads + - explicit empty, permission, expired, and unavailable states + - DE/EN-ready labels for customer-facing review text + - progressive disclosure for technical detail instead of operator-default density - **Non-scope**: - - admin settings - - remediation actions - - raw operator diagnostics - - a broader customer portal rewrite - - billing or contract workflows + - a new customer portal, separate identity plane, or broader customer product shell + - policy remediation, restore, or admin actions for customers + - a new review engine, evidence engine, or report-generation engine + - AI-generated summaries + - public-link sharing without authentication or RBAC + - raw operator diagnostics, provider-debug data, or raw evidence payloads as default-visible content +- **Decision workflow**: + - customer reviewers can read released reviews, evidence summaries, and review packs + - customer acknowledgement can return later as a narrower v1.1 or v2 follow-up + - no technical remediation or admin mutation lives in this workspace +- **Capability review**: + - validate or introduce customer-facing capability boundaries such as `reviews.customer.view`, `reviews.customer.download_pack`, `evidence.customer.view`, `findings.customer.view`, and `risks.customer.view` + - existing operator capabilities must not automatically imply customer access +- **Export posture**: + - review packs remain the primary export and proof artifact + - evidence stays narrative and customer-safe by default rather than raw-payload first + - downloads must remain auditable - **Acceptance criteria**: - - an authorized customer or read-only actor can open the review workspace - - latest review status, accepted risks, and key findings are visible without exposing admin controls - - review-pack downloads respect existing redaction and entitlement rules + - the customer-facing review workspace does not expose internal run, debug, provider, or raw JSON details by default + - findings are shown with severity, status, reason, impact, and recommendation in customer-safe wording + - accepted risks / exceptions are visible and understandable for non-operator consumers, including accountable role or person, decision timing, expiry or review status, and evidence linkage where product truth exists + - evidence is summarized without exposing raw payloads by default + - review packs are downloadable when entitlement and capability checks pass + - each relevant view and download action produces audit evidence - tenant and workspace isolation are enforced and tested - - audit-sensitive or operator-only data is not exposed through this surface -- **Notes**: This is the clearest repo-derived blocker between current internal review strength and a cleaner sellable release. + - permission gaps and expired or unavailable access states are explicit and calm + - customer-facing labels are localization-ready + - global search does not leak customer review or evidence artifacts into unintended discovery paths +- **Notes**: This is still the clearest repo-derived P0 blocker between today's operator-strong review foundations and a cleaner customer-safe sellable release. Do not split a second broad liability / accountability foundation candidate out of this unless a narrower internal expiry-cockpit or portfolio-risk gap proves separate product value. ### P1 — Enterprise Maturity -### Decision-Based Governance Inbox v1 +### Governance Decision Surface Convergence - **Priority**: P1 -- **Why this stays active**: Findings, alerts, operation runs, review-pack generation, and portfolio triage already exist, but operators still work across several surfaces. The next maturity step is a single decision-oriented work surface, not more raw detail pages. +- **Why this stays active**: The repo already has Governance Inbox, My Findings, Intake, Exception Queue, alerts, and review-linked action entry points. The open gap is no longer the first governance inbox; it is convergence across these repo-real surfaces so operators stop hopping between adjacent work queues. - **Roadmap relationship**: Findings workflow maturity; later MSP Portfolio OS prerequisite. +- **Existing implementation context**: + - Spec 250 (`decision-governance-inbox`) delivered the first decision-oriented governance inbox surface. + - Specs 221, 222, 224, 225, 230, and 231 already cover major inbox, intake, notification, and workflow-adoption slices. + - `CustomerReviewWorkspace` and `FindingExceptionsQueue` now act as adjacent decision surfaces that should converge around one calmer operator journey instead of multiplying parallel entry points. - **Dependencies**: - findings workflow semantics and inbox foundations from Specs 219, 221, 222, 224, 225, 230, 231 - alert routing foundation - `OperationRun` truth - portfolio triage continuity + - customer review and exception governance surfaces where decision work overlaps - contextual help and reason-code surfaces where helpful - **Scope**: - - one operator-facing inbox for high-signal governance work - - grouping or prioritization across findings, alerts, stale runs, and related attention signals - - direct action links into compare, finding review, review-pack generation, or triage paths - - auditable state changes such as snooze, assign, or acknowledge where already supported + - one decision-centered operator entry model across more than one existing queue or signal family + - reduce surface-hopping between My Findings, Intake, Governance Inbox, Exception Queue, and adjacent high-signal attention states + - preserve direct action links into compare, finding review, review-pack generation, exception handling, or triage paths instead of duplicating domain state + - add convergence rules, prioritization, and clearer routing before inventing more list surfaces + - auditable state changes such as snooze, assign, or acknowledge only where those state mutations already exist as product truth - **Non-scope**: + - rebuilding the first governance inbox from scratch - autonomous remediation - AI-generated recommendations - customer-facing inboxes - full cross-tenant workboard redesign - **Acceptance criteria**: - - one surface shows prioritized governance work from more than one underlying signal family - - actions route to existing product truth rather than duplicating state + - operators can start from one decision-centered surface or convergence model that spans more than one existing signal family or queue + - existing surfaces keep one consistent routing model instead of growing more parallel queue concepts + - actions route to existing product truth rather than creating duplicate state or duplicate work ownership - visibility is capability-aware and workspace-safe - auditable state changes are recorded where the inbox mutates work state - - tests prove signal grouping and authorization boundaries -- **Notes**: Important, but not a P0 release blocker while Customer Review Workspace is still missing. + - tests prove signal grouping, routing, and authorization boundaries +- **Notes**: This is a follow-up to the existing Governance Inbox, not a greenfield inbox foundation. ### Cross-Tenant Compare and Promotion v1 - **Priority**: P1 @@ -112,32 +162,6 @@ ### Cross-Tenant Compare and Promotion v1 - audit trail exists for compare and promotion entry points - the slice refreshes or narrows Spec 043 instead of reopening it as a vague ambition -### Localization v1 -- **Priority**: P1 -- **Why this stays active**: The repo and roadmap both indicate this is still absent. It is not a backend foundation gap; it is a product maturity gap that will get more expensive as the governance surface grows. -- **Roadmap relationship**: R1.9 Platform Localization v1. -- **Dependencies**: - - existing status and terminology catalogs - - contextual help boundaries - - notification and UI copy inventory on critical surfaces - - locale resolution rules for workspace, user, and system context -- **Scope**: - - `de` and `en` on core governance surfaces - - locale resolution order and fallback behavior - - locale-aware formatting for dates, times, and numbers - - stable machine and export formats that remain non-localized -- **Non-scope**: - - public website localization - - broad documentation translation - - retrospective translation of every legacy free-text record - - marketing copy systems -- **Acceptance criteria**: - - core navigation, dashboard, findings, baseline compare, alerts, and operations surfaces support `de` and `en` - - no raw translation keys appear on critical UI paths - - fallback to English is controlled and predictable - - locale-aware formatting does not affect audit or export truth - - targeted regression coverage exists for fallback and key critical flows - ### Remove Findings Lifecycle Backfill Runtime Surfaces - **Priority**: P1 - **Why this stays active**: Repo audit shows visible runtime surfaces for a pre-production findings lifecycle repair path even though active finding generators already write the relevant lifecycle fields directly. The remaining path is not just ballast; it appears partially detached from current operational-control truth and keeps internal repair tooling productized. @@ -256,6 +280,63 @@ ### Commercial Entitlements and Billing-State Maturity - changes and overrides are audited - tests cover blocked and allowed paths +### Compliance Evidence Mapping v1 +- **Priority**: P2 +- **Why this stays active**: Canonical control catalog, evidence snapshots, stored reports, review packs, findings, and accepted-risk foundations are already repo-real. The missing gap is a versioned mapping layer from technical governance truth to customer-safe control or readiness views, not another control foundation rewrite. +- **Roadmap relationship**: Compliance moat / executive review follow-through. +- **Dependencies**: + - canonical control catalog foundation + - evidence snapshots and stored reports + - findings and accepted-risk workflow + - tenant reviews and review-pack export + - customer review productization and export maturity +- **Scope**: + - one versioned control interpretation layer and one bounded overlay for a first customer-safe readiness/control view + - map findings, evidence, and accepted risks to customer-safe control views without certification claims + - show control, evidence, and recommendation linkage in one primary review or export surface before broad multi-surface rollout + - keep framework overlays downstream from the shared canonical control model +- **Non-scope**: + - certification claims or legal guarantees + - hard-coded BSI, NIS2, CIS, or ISO semantics deep in the platform core + - separate technical control object models per framework + - full GRC suite or lawyer-facing workflow +- **Acceptance criteria**: + - one bounded overlay maps existing technical truth to a control or readiness view + - one concrete review or export surface can show control status, evidence linkage, and recommended action from shared foundations + - mapping versions are explicit and auditable + - the product clearly separates technical findings from regulatory interpretation + - no framework-specific one-off output bypasses the common evidence, findings, exception, and export pipeline +- **Smallest useful v1**: start with one overlay family and one customer-safe output path. Do not start by modeling multiple frameworks, multiple customer profiles, and multiple output surfaces at once. + +### Governance-as-a-Service Packaging v1 +- **Priority**: P2 +- **Why this stays active**: Review packs, evidence snapshots, stored reports, customer review foundations, and accepted-risk workflow are repo-real. The missing gap is repeatable MSP/customer-safe packaging, not raw reporting substrate. +- **Roadmap relationship**: MSP sellability / recurring governance service. +- **Dependencies**: + - customer review workspace productization + - compliance evidence mapping + - review packs, evidence snapshots, and stored reports + - findings and accepted-risk workflow + - localization, entitlements, and export maturity +- **Scope**: + - one on-demand management-ready governance package built from the existing review-pack and evidence pipeline + - executive summary with customer-safe language + - top findings, accepted risks, open decisions, and evidence links + - bounded MSP branding and packaging rules + - no scheduling, batching, or report-program engine in the first slice +- **Non-scope**: + - CRM, newsletter, or marketing automation + - PSA replacement or service-desk workflow + - raw operator-data dumps as the default deliverable + - a separate reporting engine that bypasses existing review/evidence/export truth +- **Acceptance criteria**: + - an MSP can generate one repeatable on-demand governance package from existing review, evidence, and accepted-risk artifacts + - the output is customer-safe and management-readable by default + - top findings, accepted risks, open decisions, and evidence links are clearly represented + - packaging reuses shared review/evidence/export foundations instead of creating a parallel report domain + - bounded branding or presentation options do not weaken auditability or customer-safe defaults +- **Smallest useful v1**: one management-ready package for one review context, generated on demand from existing artifacts. Leave recurring schedules, multi-pack campaigns, and broader customer-communications automation out of scope. + ### External Support Desk / PSA Handoff - **Priority**: P2 - **Why this stays active**: In-app support requests are already repo-real. The remaining gap is external handoff and visible ticket linkage, not support-request creation itself. @@ -292,7 +373,55 @@ ## Deferred / Existing Drafts Outside the Current Queue These items are still useful, but they are not the next best open specs from the current repo state. -- `Policy Lifecycle / Ghost Policies`: still a valid gap, but not ahead of Customer Review Workspace or Cross-Tenant Compare. +### Workspace, Tenant & Managed Object Lifecycle Governance v1 + +- **Priority**: P2 — Important hardening / enterprise trust +- **Status**: Strategic candidate, not ready for immediate implementation +- **Do not prep before**: Customer Review Workspace, Cross-Tenant Compare & Promotion, Governance Decision Convergence, and current sellability/productization follow-through are materially closed. +- **Why this replaces `Policy Lifecycle / Ghost Policies`**: A policy-only ghost lifecycle spec risks introducing local deletion semantics, Laravel `SoftDeletes`, or overloaded `ignored_at` behavior before TenantPilot has a clear platform lifecycle taxonomy. The real roadmap-fit problem is broader: TenantPilot needs consistent lifecycle truth for workspaces, tenants, managed provider objects, evidence, backups, restoreability, export, retention, and purge. +- **Problem**: Lifecycle concerns currently appear across separate product areas such as policies, restore flows, commercial state, workspace entitlements, backup history, evidence snapshots, audit, support, and workspace administration. Without one shared taxonomy, local fixes can collapse different meanings into the same field or UI label: provider object missing, local TenantPilot record deleted, operator ignored the item, workspace suspended, data retained for compliance, data eligible for purge, or restore no longer possible. +- **Product goal**: Define an enterprise-grade lifecycle model before implementing destructive or retention-sensitive workflows. TenantPilot must distinguish at least these dimensions: + - **Local record lifecycle**: active, archived, locally removed, purge scheduled, purged + - **Provider presence lifecycle**: present, missing from provider, provider deleted, reappeared + - **Operator suppression lifecycle**: visible, ignored / suppressed, restored to visibility + - **Commercial / workspace lifecycle**: trial, active, grace, suspended read-only, closed + - **Retention / compliance lifecycle**: retained, export requested, deletion requested, deletion scheduled, legal hold / retention hold, purge due, purged + - **Restoreability lifecycle**: restorable, metadata only, blocked by dependency, not restorable, expired by retention +- **Smallest useful v1**: Do not implement deletion flows immediately. First define the lifecycle taxonomy, naming rules, state boundaries, audit expectations, OperationRun expectations, retention boundaries, and implementation guardrails for future specs. +- **Questions v1 must answer**: + - What does “deleted” mean in TenantPilot? + - What does “missing from provider” mean? + - What does “ignored” mean? + - What happens when a tenant is removed from a workspace? + - What happens when a workspace is suspended or closed? + - What data remains visible in read-only or suspended states? + - What data must be exportable before deletion? + - What data is retained for audit, evidence, or legal reasons? + - What can be purged, and what must never be purged automatically? + - Which lifecycle transitions require explicit human confirmation? + - Which transitions require audit events? + - Which transitions require OperationRun truth? + - Which transitions affect restore eligibility? +- **Explicit non-goals for v1**: + - no immediate workspace deletion implementation + - no immediate tenant deletion implementation + - no purge engine + - no hard-delete workflow + - no policy-only ghost lifecycle patch + - no Laravel `SoftDeletes` rollout + - no migration that reinterprets existing `ignored_at` data + - no new lifecycle dashboard or workboard + - no new restore engine + - no payment-provider or billing integration +- **Expected follow-up specs after taxonomy approval**: + 1. `Provider-Missing Managed Object Truth v1` — explicit provider-missing state for policies and later other managed objects, no local deletion semantics, restore continuity where backup-backed history exists. + 2. `Workspace & Tenant Closure Lifecycle v1` — close workspace, remove tenant from workspace, define read-only / suspended / closed behavior, no destructive purge yet. + 3. `Data Export Before Deletion v1` — export customer-owned evidence, reports, audit-relevant artifacts, restore metadata, and tenant/workspace records before deletion. + 4. `Retention & Purge Governance v1` — retention periods, legal hold, purge eligibility, irreversible deletion confirmation, and audit trail. + 5. `Restoreability Expiry & Evidence Retention v1` — distinguish restorable backup payloads from retained evidence/audit metadata and define when restore is no longer possible but evidence remains retained. +- **Roadmap fit**: This is not a P0 sales feature. It is a P2 enterprise trust and compliance hardening candidate that becomes important before serious production customer offboarding, destructive data operations, or regulated retention commitments. It must not block Customer Review Workspace Productization, Governance Decision Surface Convergence, or Cross-Tenant Compare & Promotion. +- **Candidate decision**: Keep as strategic candidate. Do not implement a narrow Ghost Policy spec until the lifecycle taxonomy is agreed. If provider-missing policy behavior becomes an immediate product bug, create a smaller follow-up spec named `Provider-Missing Policy Visibility & Restore Continuity v1`; that smaller spec must use `provider_deleted_at`, `missing_from_provider_at`, or an equivalent provider-presence field and must not use Laravel `SoftDeletes` or local deletion semantics. + - `Workspace-level PII override for review packs`: bounded deferred follow-up from Spec 109. - `CSV export for filtered run metadata`: valid system-console follow-up, but not near the top of the queue. - `Raw error/context drilldowns for system console`: useful operator enhancement, but not ahead of current P0-P2 gaps. @@ -312,6 +441,8 @@ ## Promoted to Spec - In-App Support Request with Context -> Spec 246 (`support-request-context`) - Plans, Entitlements & Billing Readiness -> Spec 247 (`plans-entitlements-billing-readiness`) - Private AI Execution & Policy Foundation -> Spec 248 (`private-ai-policy-foundation`) +- Customer Review Workspace v1 -> Spec 249 (`customer-review-workspace`) +- Decision-Based Governance Inbox v1 -> Spec 250 (`decision-governance-inbox`) - Queued Execution Reauthorization and Scope Continuity -> Spec 149 (`queued-execution-reauthorization`) - Livewire Context Locking and Trusted-State Reduction -> Spec 152 (`livewire-context-locking`) - Evidence Domain Foundation -> Spec 153 (`evidence-domain-foundation`) @@ -353,4 +484,5 @@ ## Superseded / Removed From Active Queue - `In-App Support Request with Context`: remove from active candidates because it is already Spec 246 and repo-implemented. - `Plans, Entitlements & Billing Readiness`: remove as a broad active candidate because Spec 247 already exists and the remaining open gap is narrower commercial lifecycle maturity. - `Private AI Execution & Policy Foundation`: remove from the active queue because Spec 248 already exists. +- `Localization v1`: remove as a broad active candidate because the locale foundation is already repo-real; the remaining work is surface adoption, copy/glossary completion, and customer-facing polish inside narrower productization or UI-maturity follow-ups. - Company-ops items such as `Lead Capture & CRM Pipeline`, `AVV / DPA / TOM / Legal Pack`, `Vendor Questionnaire Answer Bank`, `Business Continuity / Founder Backup Plan`, and similar operating artifacts should remain outside the active product-spec queue unless a concrete product slice emerges. diff --git a/docs/research/admin-canonical-tenant-rollout.md b/docs/research/admin-canonical-tenant-rollout.md index d2201fa3..16475051 100644 --- a/docs/research/admin-canonical-tenant-rollout.md +++ b/docs/research/admin-canonical-tenant-rollout.md @@ -1,5 +1,10 @@ # Admin Canonical Tenant Rollout +> **Status:** Historical +> **Last reviewed:** 2026-04-30 +> **Use for:** Historical rollout notes for the admin canonical tenant transition +> **Do not use for:** Current implementation truth without checking the corresponding specs and code + ## Purpose Spec 136 completes the workspace-admin canonical tenant rule across admin-visible and admin-reachable shared surfaces. Workspace-admin requests under `/admin/...` resolve tenant context through `App\Support\OperateHub\OperateHubShell::activeEntitledTenant(request())`. Tenant-panel requests under `/admin/t/{tenant}/...` keep panel-native tenant semantics. diff --git a/docs/research/canonical-tenant-context-resolution.md b/docs/research/canonical-tenant-context-resolution.md index 13567c8d..588538de 100644 --- a/docs/research/canonical-tenant-context-resolution.md +++ b/docs/research/canonical-tenant-context-resolution.md @@ -1,5 +1,10 @@ # Canonical Tenant Context Resolution +> **Status:** Historical +> **Last reviewed:** 2026-04-30 +> **Use for:** Historical context for the canonical tenant resolution rule and exception model +> **Do not use for:** Current path truth or current panel behavior without repo verification + ## Canonical Rule - Tenant-panel and tenant-scoped flows keep panel-native tenant semantics through `Filament::getTenant()` / `Tenant::current()`. diff --git a/docs/research/filament-v5-notes.md b/docs/research/filament-v5-notes.md index c7dc91ad..2a8b83b6 100644 --- a/docs/research/filament-v5-notes.md +++ b/docs/research/filament-v5-notes.md @@ -1,5 +1,10 @@ # SECTION A — FILAMENT V5 NOTES (BULLETS + SOURCES) +> **Status:** Active +> **Last reviewed:** 2026-04-30 +> **Use for:** Filament v5 reference notes and framework-specific decision checks when local behavior is uncertain +> **Do not use for:** Assuming local implementation already follows every note without repo verification + ## Versioning & Base Requirements - Rule: Filament v5 requires Livewire v4.0+. When: Always when installing/upgrading Filament v5. When NOT: Never target Livewire v3 in a v5 codebase. diff --git a/docs/research/golden-master-baseline-drift-deep-analysis.md b/docs/research/golden-master-baseline-drift-deep-analysis.md index c548e0b1..e5cbe14f 100644 --- a/docs/research/golden-master-baseline-drift-deep-analysis.md +++ b/docs/research/golden-master-baseline-drift-deep-analysis.md @@ -1,5 +1,10 @@ # Golden Master / Baseline Drift — Deep Settings-Drift (Content-Fidelity) Analysis +> **Status:** Reference +> **Last reviewed:** 2026-04-30 +> **Use for:** Deep drift-engine research, architectural rationale, and fidelity trade-off analysis +> **Do not use for:** Current implementation truth or roadmap priority without repo verification +> > Enterprise Research Report for TenantAtlas / TenantPilot > Date: 2025-07-15 > Scope: Architecture, code evidence, implementation proposal diff --git a/docs/research/m365-policy-coverage-gap-analysis.md b/docs/research/m365-policy-coverage-gap-analysis.md index 85d6a610..ef8f1c7c 100644 --- a/docs/research/m365-policy-coverage-gap-analysis.md +++ b/docs/research/m365-policy-coverage-gap-analysis.md @@ -1,5 +1,10 @@ # TenantPilot – M365 Policy Coverage Gap Analysis +> **Status:** Reference +> **Last reviewed:** 2026-04-30 +> **Use for:** Coverage expansion planning and M365 policy gap research +> **Do not use for:** Current productization priority order without roadmap review + **Date:** 2026-03-07 **Author:** Gap Analysis (Automated Deep Research) **Scope:** Security, Governance & Baseline-relevante Policy-Familien über Microsoft 365 hinweg diff --git a/docs/security/REDACTION_AUDIT_REPORT.md b/docs/security/REDACTION_AUDIT_REPORT.md index e3aa36ea..54753c60 100644 --- a/docs/security/REDACTION_AUDIT_REPORT.md +++ b/docs/security/REDACTION_AUDIT_REPORT.md @@ -1,5 +1,10 @@ # Redaction / Masking / Sanitizing — Codebase Audit Report +> **Status:** Needs Review +> **Last reviewed:** 2026-04-30 +> **Use for:** Security and data-integrity audit findings around redaction behavior and masking risks +> **Do not use for:** Assuming every finding is still open without verifying the current codebase + **Auditor:** Security + Data-Integrity Codebase Auditor **Date:** 2026-03-06 **Scope:** Entire TenantAtlas repo (excluding `vendor/`, `node_modules/`, compiled views) diff --git a/docs/strategy/domain-coverage.md b/docs/strategy/domain-coverage.md index f9de0475..7b7c8531 100644 --- a/docs/strategy/domain-coverage.md +++ b/docs/strategy/domain-coverage.md @@ -1,5 +1,10 @@ # Domain Coverage Map +> **Status:** Reference +> **Last reviewed:** 2026-04-30 +> **Use for:** Domain classification, planning boundaries, and evaluating which Microsoft domains fit which TenantPilot product primitives +> **Do not use for:** Current release priority or implementation truth without roadmap, spec, and code verification +> > Canonical classification of Microsoft domains for TenantPilot platform planning. > This document defines which domains receive which product primitives and why. diff --git a/docs/strategy/product-vision.md b/docs/strategy/product-vision.md index e69de29b..d72d6b61 100644 --- a/docs/strategy/product-vision.md +++ b/docs/strategy/product-vision.md @@ -0,0 +1,27 @@ +# TenantPilot Product Vision + +> **Status:** Active +> **Last reviewed:** 2026-04-30 +> **Use for:** Long-term product direction and roadmap alignment +> **Do not use for:** Implementation truth without repo verification + +TenantPilot is a Governance-of-Record platform for Microsoft tenant governance, evidence-first reviews, MSP portfolio operations, Intune backup/restore, auditability, customer-safe review consumption, and decision-based governance workflows. + +TenantPilot is not a generic Microsoft admin mirror. + +## Core Principles + +- OperationRun Truth Layer +- Evidence-first Reporting +- Customer-safe Review Consumption +- Decision-first, diagnostics-second, evidence-third UX +- Capability-first RBAC +- Workspace-first Multi-Tenancy +- Provider-extensible Architecture +- Auditability +- MSP Portfolio Governance +- Enterprise SaaS UX over admin-tool sprawl + +## Strategic Direction + +The platform should prioritize productization of existing foundations before adding isolated technical coverage. New coverage should strengthen reviews, evidence, findings, baselines, governance inbox, or MSP portfolio workflows. diff --git a/docs/strategy/website-working-contract.md b/docs/strategy/website-working-contract.md index 5166933d..3500c432 100644 --- a/docs/strategy/website-working-contract.md +++ b/docs/strategy/website-working-contract.md @@ -1,5 +1,10 @@ # Website Working Contract +> **Status:** Reference +> **Last reviewed:** 2026-04-30 +> **Use for:** Guardrails around keeping `apps/website` an intentionally independent track +> **Do not use for:** Introducing new runtime coupling without explicit contract changes and repo verification +> > Guardrails for evolving `apps/website` as an independently evolvable track in the current repository. > This document is repo-truth-based and describes the currently verified state, not a speculative future architecture. diff --git a/docs/ui/action-surface-contract.md b/docs/ui/action-surface-contract.md index 77ef2fe9..3c037c9c 100644 --- a/docs/ui/action-surface-contract.md +++ b/docs/ui/action-surface-contract.md @@ -1,5 +1,10 @@ # Action surface contract +> **Status:** Active +> **Last reviewed:** 2026-04-30 +> **Use for:** Action placement and affordance rules for Filament resources, pages, and relation managers +> **Do not use for:** Skipping authorization, confirmation, or resource-specific UX review + This project enforces a small “action surface contract” for Filament Resources / Pages / RelationManagers to keep table UIs consistent, quiet, and safe. ## Inspect affordance (required) diff --git a/docs/ui/filament-table-standard.md b/docs/ui/filament-table-standard.md index 5441bc2f..15ef4bae 100644 --- a/docs/ui/filament-table-standard.md +++ b/docs/ui/filament-table-standard.md @@ -1,5 +1,10 @@ # Filament Table Standard +> **Status:** Active +> **Last reviewed:** 2026-04-30 +> **Use for:** Standard list-surface rules for production Filament tables +> **Do not use for:** Overriding product-specific needs without an explicit documented exception + ## Standard TenantPilot standardizes production Filament list surfaces with a convention-first model: diff --git a/docs/ui/operator-ux-surface-standards.md b/docs/ui/operator-ux-surface-standards.md index c0a67712..d555286a 100644 --- a/docs/ui/operator-ux-surface-standards.md +++ b/docs/ui/operator-ux-surface-standards.md @@ -1,5 +1,10 @@ # Operator UX & Surface Standards +> **Status:** Active +> **Last reviewed:** 2026-04-30 +> **Use for:** Audience, language, disclosure, and operator-surface rules for product UX +> **Do not use for:** Treating internal implementation structure as product IA or redefining constitution terms locally + This document defines the binding audience-and-surface contract for TenantPilot. It establishes: diff --git a/docs/ui/shared-diff-presentation-foundation.md b/docs/ui/shared-diff-presentation-foundation.md index 0112ea92..a7e2c16e 100644 --- a/docs/ui/shared-diff-presentation-foundation.md +++ b/docs/ui/shared-diff-presentation-foundation.md @@ -1,5 +1,10 @@ # Shared Diff Presentation Foundation +> **Status:** Reference +> **Last reviewed:** 2026-04-30 +> **Use for:** Reusable presentation rules for simple before/after comparison surfaces +> **Do not use for:** Domain diff logic, data fetching, or token-level compare behavior + ## Purpose Use the shared diff presentation foundation when a screen already has simple before/after data and needs: diff --git a/spechistory/spec.md b/spechistory/spec.md index 1ac2a6bb..d291eea0 100644 --- a/spechistory/spec.md +++ b/spechistory/spec.md @@ -1,5 +1,10 @@ # Feature Specification: TenantPilot v1 +> **Status:** Historical +> **Last reviewed:** 2026-04-30 +> **Use for:** Early product-history context and original v1 framing +> **Do not use for:** Current implementation truth, current roadmap priority, or current spec structure without repo verification + **Feature Branch**: `tenantpilot-v1` **Created**: 2025-12-10 **Status**: Draft diff --git a/specs/003-settings-catalog-readable/IMPLEMENTATION_STATUS.md b/specs/003-settings-catalog-readable/IMPLEMENTATION_STATUS.md index e3344649..a704fe2d 100644 --- a/specs/003-settings-catalog-readable/IMPLEMENTATION_STATUS.md +++ b/specs/003-settings-catalog-readable/IMPLEMENTATION_STATUS.md @@ -1,5 +1,10 @@ # Feature 003: Implementation Status Report +> **Status:** Needs Review +> **Last reviewed:** 2026-04-30 +> **Use for:** Historical implementation-progress context for Spec 003 +> **Do not use for:** Proof that the remaining manual verification or tests were completed in the current repo state + ## Executive Summary **Status**: ✅ **Core Implementation Complete** (Phases 1-5) diff --git a/specs/003-settings-catalog-readable/MANUAL_TESTING_CHECKLIST.md b/specs/003-settings-catalog-readable/MANUAL_TESTING_CHECKLIST.md index 5b98e306..72332835 100644 --- a/specs/003-settings-catalog-readable/MANUAL_TESTING_CHECKLIST.md +++ b/specs/003-settings-catalog-readable/MANUAL_TESTING_CHECKLIST.md @@ -1,5 +1,10 @@ # Feature 003: Manual Testing Checklist +> **Status:** Reference +> **Last reviewed:** 2026-04-30 +> **Use for:** Manual verification scenarios for Spec 003 if that surface needs targeted re-checks +> **Do not use for:** Proof that manual testing was executed or passed in the current branch + ## Prerequisites 1. **Start the application:** diff --git a/specs/feat/700-bugfix/plan.md b/specs/feat/700-bugfix/plan.md deleted file mode 100644 index 4d3427dc..00000000 --- a/specs/feat/700-bugfix/plan.md +++ /dev/null @@ -1,26 +0,0 @@ -# Implementation Plan: BaselineCompareRun model bugfix - -**Branch**: `feat/700-bugfix` | **Date**: 2026-02-20 | **Spec**: `specs/feat/700-bugfix/spec.md` - -## Summary - -Fix runtime crash caused by a missing Eloquent model referenced by a Filament dashboard widget. - -## Technical Context - -- PHP 8.4.x, Laravel 12 -- Filament v5, Livewire v4 -- PostgreSQL (Sail locally) -- Tests: Pest v4 (`vendor/bin/sail artisan test --compact`) - -## Approach - -1. Identify intended storage for baseline compare runs: - - If a `baseline_compare_runs` table already exists, implement `App\Models\BaselineCompareRun` mapped to it. - - If not, align the widget to an existing persistence type (likely `OperationRun`) without changing UX. -2. Add a regression test that exercises the tenant dashboard route and asserts a successful response. -3. Run Pint on dirty files and run the focused test. - -## Risks - -- Introducing a new model without an existing table could still fail at runtime. Prefer minimal, compatibility-first changes. diff --git a/specs/feat/700-bugfix/spec.md b/specs/feat/700-bugfix/spec.md deleted file mode 100644 index 9e903a0c..00000000 --- a/specs/feat/700-bugfix/spec.md +++ /dev/null @@ -1,29 +0,0 @@ -# Bugfix Specification: BaselineCompareRun missing model - -**Branch**: `feat/700-bugfix` -**Created**: 2026-02-20 -**Status**: Ready - -## Problem - -Navigating to the tenant dashboard (`/admin/t/{tenant}`) throws an Internal Server Error: - -- `Class "App\Models\BaselineCompareRun" not found` - -The stack trace points to the dashboard widget `app/Filament/Widgets/Dashboard/BaselineCompareNow.php`. - -## Goal - -- Tenant dashboard loads successfully. -- Baseline compare widget can safely query baseline compare run state without a fatal error. - -## Non-Goals - -- No UX redesign. -- No new baseline-compare workflow features beyond restoring runtime stability. - -## Acceptance Criteria - -- Visiting `/admin/t/{tenant}` does not throw a 500. -- The widget renders even when there are no baseline compare runs. -- A focused automated test covers the regression. diff --git a/specs/feat/700-bugfix/tasks.md b/specs/feat/700-bugfix/tasks.md deleted file mode 100644 index fc830a11..00000000 --- a/specs/feat/700-bugfix/tasks.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -description: "Tasks for feat/700-bugfix (BaselineCompareRun missing model)" ---- - -# Tasks: feat/700-bugfix - -**Input**: `specs/feat/700-bugfix/spec.md` and `specs/feat/700-bugfix/plan.md` - -## Setup -- [X] T001 Confirm whether baseline compare runs table exists - -## Tests (TDD) -- [X] T010 Add regression test for tenant dashboard (no 500) - -## Core -- [X] T020 Fix missing BaselineCompareRun reference (model or widget) - -## Validation -- [X] T030 Run Pint (dirty) -- [X] T040 Run focused tests via Sail -- 2.45.2 From 55338a88c69044c632cb006b7e7b066fbd2659b9 Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 30 Apr 2026 18:33:56 +0000 Subject: [PATCH 35/36] merge: platform-dev into dev (#311) ## Summary - sync platform-dev back into dev with the latest integrated feature and spec work - include the customer review workspace productization flow and its related review, review-pack, evidence, audit, and test updates - carry forward the recent governance and roadmap/spec updates already merged on platform-dev ## Included highlights - customer review workspace productization and customer-safe released-review drilldown - governance decision convergence work - cross-tenant compare and promotion work - external support desk handoff work - product, roadmap, permissions, and spec artifact updates ## Validation context - platform-dev currently contains the already-validated feature work from the merged branch PRs - latest customer review workspace batch included focused Pest suites, one bounded browser smoke, and Pint ## Notes - this is an integration PR from platform-dev into dev - no separate provider-registration or asset-strategy expansion is introduced by the customer review workspace slice Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/311 --- .../Pages/Reviews/CustomerReviewWorkspace.php | 136 ++++++- .../Resources/EvidenceSnapshotResource.php | 39 +- .../Pages/ViewEvidenceSnapshot.php | 53 +++ .../Filament/Resources/ReviewPackResource.php | 45 ++- .../Pages/ViewReviewPack.php | 14 + .../Resources/TenantReviewResource.php | 57 ++- .../Pages/ViewTenantReview.php | 77 ++++ .../app/Support/Audit/AuditActionId.php | 6 + apps/platform/lang/de/localization.php | 22 ++ apps/platform/lang/en/localization.php | 22 ++ .../entries/tenant-review-summary.blade.php | 25 +- .../CustomerReviewWorkspaceSmokeTest.php | 10 +- .../Evidence/EvidenceSnapshotAuditLogTest.php | 32 ++ .../Evidence/EvidenceSnapshotResourceTest.php | 50 +++ .../ReviewPack/ReviewPackDownloadTest.php | 7 +- .../ReviewPack/ReviewPackResourceTest.php | 35 ++ ...stomerReviewWorkspaceAuthorizationTest.php | 14 +- ...CustomerReviewWorkspaceLaunchLinksTest.php | 19 +- .../CustomerReviewWorkspacePackAccessTest.php | 54 ++- .../CustomerReviewWorkspacePageTest.php | 57 ++- .../TenantReviewExplanationSurfaceTest.php | 34 ++ .../TenantReviewUiContractTest.php | 56 +++ .../checklists/requirements.md | 54 +++ ...ustomer-review-productization.openapi.yaml | 299 +++++++++++++++ .../data-model.md | 273 ++++++++++++++ .../plan.md | 301 +++++++++++++++ .../quickstart.md | 55 +++ .../research.md | 156 ++++++++ .../spec.md | 348 ++++++++++++++++++ .../tasks.md | 205 +++++++++++ 30 files changed, 2512 insertions(+), 43 deletions(-) create mode 100644 specs/258-customer-review-productization/checklists/requirements.md create mode 100644 specs/258-customer-review-productization/contracts/customer-review-productization.openapi.yaml create mode 100644 specs/258-customer-review-productization/data-model.md create mode 100644 specs/258-customer-review-productization/plan.md create mode 100644 specs/258-customer-review-productization/quickstart.md create mode 100644 specs/258-customer-review-productization/research.md create mode 100644 specs/258-customer-review-productization/spec.md create mode 100644 specs/258-customer-review-productization/tasks.md diff --git a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php index 092f4f6f..f90720bb 100644 --- a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php +++ b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php @@ -5,13 +5,17 @@ namespace App\Filament\Pages\Reviews; use App\Filament\Resources\TenantReviewResource; +use App\Models\EvidenceSnapshot; +use App\Models\FindingException; use App\Models\Tenant; use App\Models\ReviewPack; use App\Models\TenantReview; use App\Models\User; use App\Models\Workspace; +use App\Services\Audit\WorkspaceAuditLogger; use App\Services\ReviewPackService; use App\Services\TenantReviews\TenantReviewRegisterService; +use App\Support\Audit\AuditActionId; use App\Support\Auth\Capabilities; use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Filament\TablePaginationProfiles; @@ -36,6 +40,7 @@ use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Str; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use UnitEnum; @@ -45,7 +50,7 @@ class CustomerReviewWorkspace extends Page implements HasTable public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace'; - private const string SOURCE_SURFACE = 'customer_review_workspace'; + public const string SOURCE_SURFACE = 'customer_review_workspace'; protected static bool $isDiscovered = false; @@ -109,6 +114,7 @@ public function mount(): void $this->authorizePageAccess(); $this->applyRequestedTenantPrefilter(); $this->mountInteractsWithTable(); + $this->auditWorkspaceOpen(); } protected function getHeaderActions(): array @@ -166,6 +172,10 @@ public function table(Table $table): Table ->label(__('localization.review.accepted_risks')) ->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record)) ->wrap(), + TextColumn::make('evidence_proof_state') + ->label(__('localization.review.evidence_proof')) + ->getStateUsing(fn (Tenant $record): string => $this->evidenceProofAvailability($record)) + ->wrap(), TextColumn::make('published_at') ->label(__('localization.review.published')) ->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString()) @@ -173,7 +183,8 @@ public function table(Table $table): Table ->placeholder('—'), TextColumn::make('review_pack_state') ->label(__('localization.review.review_pack')) - ->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)), + ->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)) + ->wrap(), ]) ->filters([ SelectFilter::make('tenant_id') @@ -260,6 +271,32 @@ private function authorizePageAccess(): void } } + private function auditWorkspaceOpen(): void + { + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return; + } + + app(WorkspaceAuditLogger::class)->log( + workspace: $workspace, + action: AuditActionId::CustomerReviewWorkspaceOpened, + context: [ + 'metadata' => [ + 'source_surface' => self::SOURCE_SURFACE, + 'tenant_filter_id' => $this->currentTenantFilterId(), + 'entitled_tenant_count' => count($this->authorizedTenants()), + ], + ], + actor: $user, + resourceType: 'customer_review_workspace', + resourceId: (string) $workspace->getKey(), + targetLabel: __('localization.review.customer_review_workspace'), + ); + } + private function workspaceQuery(): Builder { $user = auth()->user(); @@ -518,31 +555,116 @@ private function acceptedRiskSummary(Tenant $tenant): string $validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0); $warningCount = (int) ($riskAcceptance['warning_count'] ?? 0); - return match (true) { + $countSummary = match (true) { $statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'), $warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]), $validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]), default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]), }; + + $accountability = $this->acceptedRiskAccountability($tenant); + + return $accountability === null + ? $countSummary + : $countSummary.' '.$accountability; } private function reviewPackAvailability(Tenant $tenant): string { + if (! $this->latestPublishedReview($tenant) instanceof TenantReview) { + return __('localization.review.no_published_review_available'); + } + $pack = $this->latestReviewPack($tenant); + $user = auth()->user(); if (! $pack instanceof ReviewPack) { - return __('localization.review.unavailable'); + return __('localization.review.no_current_review_pack'); + } + + if (! $user instanceof User || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) { + return __('localization.review.review_pack_access_unavailable'); } if ($pack->status !== ReviewPackStatus::Ready->value) { - return __('localization.review.unavailable'); + return __('localization.review.review_pack_unavailable'); } if ($pack->expires_at !== null && $pack->expires_at->isPast()) { - return __('localization.review.unavailable'); + return __('localization.review.review_pack_expired'); } - return __('localization.review.available'); + return __('localization.review.review_pack_available'); + } + + private function evidenceProofAvailability(Tenant $tenant): string + { + $review = $this->latestPublishedReview($tenant); + + if (! $review instanceof TenantReview) { + return __('localization.review.no_published_review_available'); + } + + $snapshot = $review->evidenceSnapshot; + $user = auth()->user(); + + if (! $snapshot instanceof EvidenceSnapshot) { + return __('localization.review.evidence_proof_absent'); + } + + if (! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) { + return __('localization.review.evidence_proof_access_unavailable'); + } + + if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) { + return __('localization.review.evidence_proof_expired'); + } + + return __('localization.review.evidence_proof_available'); + } + + private function acceptedRiskAccountability(Tenant $tenant): ?string + { + $exception = FindingException::query() + ->with(['owner', 'approver', 'currentDecision']) + ->where('workspace_id', (int) $tenant->workspace_id) + ->where('tenant_id', (int) $tenant->getKey()) + ->current() + ->orderByRaw("case when current_validity_state in ('valid', 'expiring') then 0 else 1 end") + ->latest('approved_at') + ->latest('requested_at') + ->latest('id') + ->first(); + + if (! $exception instanceof FindingException) { + return null; + } + + $accountable = $exception->owner?->name + ?? $exception->approver?->name; + $decisionType = $exception->currentDecision?->decision_type; + $reviewDue = $exception->review_due_at ?? $exception->expires_at; + $reason = is_string($exception->request_reason) ? trim($exception->request_reason) : ''; + $parts = []; + + if (is_string($accountable) && trim($accountable) !== '') { + $parts[] = $reviewDue === null + ? __('localization.review.accepted_risk_accountable', ['name' => $accountable]) + : __('localization.review.accepted_risk_accountable_until', [ + 'name' => $accountable, + 'date' => $reviewDue->toDateString(), + ]); + } elseif (is_string($decisionType) && trim($decisionType) !== '') { + $parts[] = __('localization.review.accepted_risk_partial_accountability'); + } + + if ($reason !== '') { + $parts[] = __('localization.review.accepted_risk_reason', [ + 'reason' => Str::limit($reason, 160), + ]); + } + + return $parts === [] ? null : implode(' ', $parts); } private function navigationContext(): ?CanonicalNavigationContext diff --git a/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php b/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php index abfcbb47..7bdd6ca4 100644 --- a/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php +++ b/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php @@ -174,9 +174,12 @@ public static function infolist(Schema $schema): Schema ->label('Operation') ->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—') ->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null) - ->openUrlInNewTab(), - TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'), - TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'), + ->openUrlInNewTab() + ->hidden(fn (): bool => static::isCustomerWorkspaceFlow()), + TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono') + ->hidden(fn (): bool => static::isCustomerWorkspaceFlow()), + TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono') + ->hidden(fn (): bool => static::isCustomerWorkspaceFlow()), ]) ->columns(2), Section::make('Summary') @@ -222,6 +225,7 @@ public static function infolist(Schema $schema): Schema ->label('Raw summary JSON') ->view('filament.infolists.entries.snapshot-json') ->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : []) + ->hidden(fn (): bool => static::isCustomerWorkspaceFlow()) ->columnSpanFull(), ]) ->columns(4), @@ -236,7 +240,7 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array { $entries = []; - if (is_numeric($record->operation_run_id)) { + if (! static::isCustomerWorkspaceFlow() && is_numeric($record->operation_run_id)) { $entries[] = RelatedContextEntry::available( key: 'operation_run', label: 'Operation', @@ -255,12 +259,20 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array ->first(); if ($pack instanceof \App\Models\ReviewPack && $pack->tenant instanceof Tenant) { + $packUrl = ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant); + + if (static::isCustomerWorkspaceFlow()) { + $packUrl = static::appendQuery($packUrl, [ + 'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE, + ]); + } + $entries[] = RelatedContextEntry::available( key: 'review_pack', label: 'Review pack', value: sprintf('#%d', (int) $pack->getKey()), secondaryValue: 'Inspect the latest executive-pack output for this evidence basis.', - targetUrl: ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant), + targetUrl: $packUrl, targetKind: 'direct_record', priority: 20, actionLabel: 'View review pack', @@ -285,6 +297,23 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array return $entries; } + public static function isCustomerWorkspaceFlow(): bool + { + return request()->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE; + } + + /** + * @param array $query + */ + private static function appendQuery(string $url, array $query): string + { + if ($query === []) { + return $url; + } + + return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query); + } + public static function table(Table $table): Table { return $table diff --git a/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php b/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php index 20073955..18e6aff9 100644 --- a/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php +++ b/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php @@ -5,8 +5,13 @@ namespace App\Filament\Resources\EvidenceSnapshotResource\Pages; use App\Filament\Resources\EvidenceSnapshotResource; +use App\Filament\Pages\Reviews\CustomerReviewWorkspace; +use App\Models\EvidenceSnapshot; +use App\Models\Tenant; use App\Models\User; +use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Evidence\EvidenceSnapshotService; +use App\Support\Audit\AuditActionId; use App\Support\Auth\Capabilities; use App\Support\Rbac\UiEnforcement; use App\Support\Ui\GovernanceActions\GovernanceActionCatalog; @@ -20,6 +25,13 @@ class ViewEvidenceSnapshot extends ViewRecord { protected static string $resource = EvidenceSnapshotResource::class; + public function mount(int|string $record): void + { + parent::mount($record); + + $this->auditCustomerWorkspaceProofOpen(); + } + protected function resolveRecord(int|string $key): Model { return EvidenceSnapshotResource::resolveScopedRecordOrFail($key); @@ -27,6 +39,10 @@ protected function resolveRecord(int|string $key): Model protected function getHeaderActions(): array { + if (EvidenceSnapshotResource::isCustomerWorkspaceFlow()) { + return []; + } + $refreshRule = GovernanceActionCatalog::rule('refresh_evidence'); $expireRule = GovernanceActionCatalog::rule('expire_snapshot'); @@ -90,4 +106,41 @@ protected function getHeaderActions(): array ->apply(), ]; } + + private function auditCustomerWorkspaceProofOpen(): void + { + if (! EvidenceSnapshotResource::isCustomerWorkspaceFlow()) { + return; + } + + $record = $this->record; + $user = auth()->user(); + + if (! $record instanceof EvidenceSnapshot || ! $user instanceof User) { + return; + } + + $tenant = $record->tenant; + + if (! $tenant instanceof Tenant) { + return; + } + + app(WorkspaceAuditLogger::class)->log( + workspace: $tenant->workspace, + action: AuditActionId::EvidenceSnapshotOpened, + context: [ + 'metadata' => [ + 'evidence_snapshot_id' => (int) $record->getKey(), + 'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE, + ], + ], + actor: $user, + resourceType: 'evidence_snapshot', + resourceId: (string) $record->getKey(), + targetLabel: sprintf('Evidence snapshot #%d', (int) $record->getKey()), + tenant: $tenant, + operationRunId: $record->operation_run_id !== null ? (int) $record->operation_run_id : null, + ); + } } diff --git a/apps/platform/app/Filament/Resources/ReviewPackResource.php b/apps/platform/app/Filament/Resources/ReviewPackResource.php index 68fd4537..d589692d 100644 --- a/apps/platform/app/Filament/Resources/ReviewPackResource.php +++ b/apps/platform/app/Filament/Resources/ReviewPackResource.php @@ -148,7 +148,8 @@ public static function infolist(Schema $schema): Schema TextEntry::make('file_size') ->label('File size') ->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—'), - TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—'), + TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—') + ->hidden(fn (): bool => static::isCustomerWorkspaceFlow()), ]) ->columns(2) ->columnSpanFull(), @@ -184,6 +185,7 @@ public static function infolist(Schema $schema): Schema ->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'), ]) ->columns(2) + ->hidden(fn (): bool => static::isCustomerWorkspaceFlow()) ->columnSpanFull(), Section::make('Metadata') @@ -227,9 +229,12 @@ public static function infolist(Schema $schema): Schema return OperationRunLinks::tenantlessView((int) $record->operation_run_id); }) ->openUrlInNewTab() + ->hidden(fn (): bool => static::isCustomerWorkspaceFlow()) ->placeholder('—'), - TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'), - TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—'), + TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—') + ->hidden(fn (): bool => static::isCustomerWorkspaceFlow()), + TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—') + ->hidden(fn (): bool => static::isCustomerWorkspaceFlow()), TextEntry::make('created_at')->label('Created')->dateTime(), ]) ->columns(2) @@ -243,9 +248,7 @@ public static function infolist(Schema $schema): Schema TextEntry::make('evidenceSnapshot.id') ->label('Snapshot') ->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—') - ->url(fn (ReviewPack $record): ?string => $record->evidenceSnapshot - ? TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant) - : null), + ->url(fn (ReviewPack $record): ?string => static::evidenceSnapshotUrl($record)), TextEntry::make('evidenceSnapshot.completeness_state') ->label('Snapshot completeness') ->badge() @@ -429,6 +432,36 @@ public static function getPages(): array ]; } + public static function isCustomerWorkspaceFlow(): bool + { + return request()->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE; + } + + private static function evidenceSnapshotUrl(ReviewPack $record): ?string + { + if (! $record->evidenceSnapshot) { + return null; + } + + $url = TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant); + + return static::isCustomerWorkspaceFlow() + ? static::appendQuery($url, ['source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE]) + : $url; + } + + /** + * @param array $query + */ + private static function appendQuery(string $url, array $query): string + { + if ($query === []) { + return $url; + } + + return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query); + } + private static function truthEnvelope(ReviewPack $record, bool $fresh = false): ArtifactTruthEnvelope { $presenter = app(ArtifactTruthPresenter::class); diff --git a/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php b/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php index 8af04ea3..c1fb2463 100644 --- a/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php +++ b/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php @@ -19,6 +19,20 @@ class ViewReviewPack extends ViewRecord protected function getHeaderActions(): array { + if (ReviewPackResource::isCustomerWorkspaceFlow()) { + return [ + Actions\Action::make('download') + ->label('Download') + ->icon('heroicon-o-arrow-down-tray') + ->color('primary') + ->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value) + ->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record, [ + 'source_surface' => \App\Filament\Pages\Reviews\CustomerReviewWorkspace::SOURCE_SURFACE, + ])) + ->openUrlInNewTab(), + ]; + } + $regenerateAction = UiEnforcement::forAction( Actions\Action::make('regenerate') ->label('Regenerate') diff --git a/apps/platform/app/Filament/Resources/TenantReviewResource.php b/apps/platform/app/Filament/Resources/TenantReviewResource.php index da7a53f1..62341052 100644 --- a/apps/platform/app/Filament/Resources/TenantReviewResource.php +++ b/apps/platform/app/Filament/Resources/TenantReviewResource.php @@ -215,6 +215,7 @@ public static function infolist(Schema $schema): Schema TextEntry::make('fingerprint') ->copyable() ->placeholder('—') + ->hidden(fn (): bool => static::isCustomerWorkspaceMode()) ->columnSpanFull() ->fontFamily('mono') ->size(TextSize::ExtraSmall), @@ -647,12 +648,19 @@ private static function summaryPresentation(TenantReview $record): array return [ 'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(), 'compressed_outcome' => static::compressedOutcome($record)->toArray(), - 'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()), + 'customer_workspace_mode' => static::isCustomerWorkspaceMode(), + 'reason_semantics' => static::isCustomerWorkspaceMode() + ? [] + : $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()), 'highlights' => $highlights, 'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [], 'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], - 'context_links' => static::summaryContextLinks($record), - 'metrics' => [ + 'context_links' => static::summaryContextLinks($record, static::isCustomerWorkspaceMode()), + 'metrics' => static::isCustomerWorkspaceMode() ? [ + ['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)], + ['label' => __('localization.review.pending_verification'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)], + ['label' => __('localization.review.verified_cleared'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)], + ] : [ ['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)], ['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)], ['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)], @@ -664,13 +672,13 @@ private static function summaryPresentation(TenantReview $record): array } /** - * @return array + * @return array */ - private static function summaryContextLinks(TenantReview $record): array + private static function summaryContextLinks(TenantReview $record, bool $customerWorkspaceMode = false): array { $links = []; - if (is_numeric($record->operation_run_id)) { + if (! $customerWorkspaceMode && is_numeric($record->operation_run_id)) { $links[] = [ 'title' => __('localization.review.operation'), 'label' => __('localization.review.open_operation'), @@ -679,7 +687,7 @@ private static function summaryContextLinks(TenantReview $record): array ]; } - if ($record->currentExportReviewPack && $record->tenant) { + if (! $customerWorkspaceMode && $record->currentExportReviewPack && $record->tenant) { $links[] = [ 'title' => __('localization.review.executive_pack'), 'label' => __('localization.review.view_executive_pack'), @@ -698,11 +706,25 @@ private static function summaryContextLinks(TenantReview $record): array } if ($record->evidenceSnapshot && $record->tenant) { + $user = auth()->user(); + $canViewEvidence = $user instanceof User && $user->can(Capabilities::EVIDENCE_VIEW, $record->tenant); + $evidenceUrl = $canViewEvidence + ? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant) + : null; + + if ($customerWorkspaceMode && $evidenceUrl !== null) { + $evidenceUrl = static::appendQuery($evidenceUrl, [ + 'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE, + ]); + } + $links[] = [ 'title' => __('localization.review.evidence_snapshot'), 'label' => __('localization.review.view_evidence_snapshot'), - 'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant), - 'description' => __('localization.review.evidence_snapshot_description'), + 'url' => $evidenceUrl, + 'description' => $canViewEvidence + ? __('localization.review.evidence_snapshot_description') + : __('localization.review.evidence_proof_access_unavailable'), ]; } @@ -783,4 +805,21 @@ private static function findingOutcomeSummary(array $summary): ?string return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts); } + + private static function isCustomerWorkspaceMode(): bool + { + return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY); + } + + /** + * @param array $query + */ + private static function appendQuery(string $url, array $query): string + { + if ($query === []) { + return $url; + } + + return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query); + } } diff --git a/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php b/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php index d6cdd157..e3829ee3 100644 --- a/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php +++ b/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php @@ -6,15 +6,18 @@ use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Resources\TenantReviewResource; +use App\Models\ReviewPack; use App\Models\Tenant; use App\Models\TenantReview; use App\Models\User; +use App\Services\ReviewPackService; use App\Services\Audit\WorkspaceAuditLogger; use App\Services\TenantReviews\TenantReviewLifecycleService; use App\Services\TenantReviews\TenantReviewService; use App\Support\Audit\AuditActionId; use App\Support\Auth\Capabilities; use App\Support\Rbac\UiEnforcement; +use App\Support\ReviewPackStatus; use App\Support\TenantReviewStatus; use App\Support\Ui\GovernanceActions\GovernanceActionCatalog; use Filament\Actions; @@ -64,6 +67,12 @@ protected function authorizeAccess(): void protected function getHeaderActions(): array { + if ($this->isCustomerWorkspaceView()) { + return [ + $this->downloadCurrentReviewPackAction(), + ]; + } + $secondaryActions = $this->secondaryLifecycleActions(); return array_values(array_filter([ @@ -343,6 +352,74 @@ private function archiveReviewAction(): Actions\Action ->apply(); } + private function downloadCurrentReviewPackAction(): Actions\Action + { + return Actions\Action::make('download_current_review_pack') + ->label(__('localization.review.download_current_review_pack')) + ->icon('heroicon-o-arrow-down-tray') + ->color('primary') + ->disabled(fn (): bool => $this->currentReviewPackDownloadUrl() === null) + ->tooltip(fn (): ?string => $this->currentReviewPackUnavailableReason()) + ->url(fn (): ?string => $this->currentReviewPackDownloadUrl()) + ->openUrlInNewTab(); + } + + private function currentReviewPackDownloadUrl(): ?string + { + $pack = $this->record->currentExportReviewPack; + $tenant = $this->record->tenant; + $user = auth()->user(); + + if (! $pack instanceof ReviewPack || ! $tenant instanceof Tenant || ! $user instanceof User) { + return null; + } + + if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) { + return null; + } + + if ($pack->status !== ReviewPackStatus::Ready->value) { + return null; + } + + if ($pack->expires_at !== null && $pack->expires_at->isPast()) { + return null; + } + + return app(ReviewPackService::class)->generateDownloadUrl($pack, [ + 'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE, + ]); + } + + private function currentReviewPackUnavailableReason(): ?string + { + if ($this->currentReviewPackDownloadUrl() !== null) { + return null; + } + + $pack = $this->record->currentExportReviewPack; + $tenant = $this->record->tenant; + $user = auth()->user(); + + if (! $pack instanceof ReviewPack) { + return __('localization.review.customer_review_pack_missing'); + } + + if (! $user instanceof User || ! $tenant instanceof Tenant || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) { + return __('localization.review.customer_review_pack_forbidden'); + } + + if ($pack->status !== ReviewPackStatus::Ready->value) { + return __('localization.review.customer_review_pack_not_ready'); + } + + if ($pack->expires_at !== null && $pack->expires_at->isPast()) { + return __('localization.review.customer_review_pack_expired'); + } + + return __('localization.review.customer_review_pack_unavailable'); + } + private function isCustomerWorkspaceView(): bool { return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY); diff --git a/apps/platform/app/Support/Audit/AuditActionId.php b/apps/platform/app/Support/Audit/AuditActionId.php index efdbe3fb..17fc1bb8 100644 --- a/apps/platform/app/Support/Audit/AuditActionId.php +++ b/apps/platform/app/Support/Audit/AuditActionId.php @@ -91,6 +91,7 @@ enum AuditActionId: string case EvidenceSnapshotCreated = 'evidence_snapshot.created'; case EvidenceSnapshotRefreshed = 'evidence_snapshot.refreshed'; case EvidenceSnapshotExpired = 'evidence_snapshot.expired'; + case EvidenceSnapshotOpened = 'evidence_snapshot.opened'; case TenantReviewCreated = 'tenant_review.created'; case TenantReviewRefreshed = 'tenant_review.refreshed'; case TenantReviewPublished = 'tenant_review.published'; @@ -98,6 +99,7 @@ enum AuditActionId: string case TenantReviewOpened = 'tenant_review.opened'; case TenantReviewExported = 'tenant_review.exported'; case TenantReviewSuccessorCreated = 'tenant_review.successor_created'; + case CustomerReviewWorkspaceOpened = 'customer_review_workspace.opened'; case ReviewPackDownloaded = 'review_pack.downloaded'; case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed'; case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed'; @@ -241,6 +243,7 @@ private static function labels(): array self::EvidenceSnapshotCreated->value => 'Evidence snapshot created', self::EvidenceSnapshotRefreshed->value => 'Evidence snapshot refreshed', self::EvidenceSnapshotExpired->value => 'Evidence snapshot expired', + self::EvidenceSnapshotOpened->value => 'Evidence snapshot opened', self::TenantReviewCreated->value => 'Tenant review created', self::TenantReviewRefreshed->value => 'Tenant review refreshed', self::TenantReviewPublished->value => 'Tenant review published', @@ -248,6 +251,7 @@ private static function labels(): array self::TenantReviewOpened->value => 'Tenant review opened', self::TenantReviewExported->value => 'Tenant review exported', self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created', + self::CustomerReviewWorkspaceOpened->value => 'Customer review workspace opened', self::ReviewPackDownloaded->value => 'Review pack downloaded', self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed', self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed', @@ -337,6 +341,7 @@ private static function summaries(): array self::EvidenceSnapshotCreated->value => 'Evidence snapshot created', self::EvidenceSnapshotRefreshed->value => 'Evidence snapshot refreshed', self::EvidenceSnapshotExpired->value => 'Evidence snapshot expired', + self::EvidenceSnapshotOpened->value => 'Evidence snapshot opened', self::TenantReviewCreated->value => 'Tenant review created', self::TenantReviewRefreshed->value => 'Tenant review refreshed', self::TenantReviewPublished->value => 'Tenant review published', @@ -344,6 +349,7 @@ private static function summaries(): array self::TenantReviewOpened->value => 'Tenant review opened', self::TenantReviewExported->value => 'Tenant review exported', self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created', + self::CustomerReviewWorkspaceOpened->value => 'Customer review workspace opened', self::ReviewPackDownloaded->value => 'Review pack downloaded', self::SupportDiagnosticsOpened->value => 'Support diagnostics opened', self::SupportRequestCreated->value => 'Support request created', diff --git a/apps/platform/lang/de/localization.php b/apps/platform/lang/de/localization.php index a062fd7e..a80668cc 100644 --- a/apps/platform/lang/de/localization.php +++ b/apps/platform/lang/de/localization.php @@ -138,10 +138,12 @@ 'latest_review' => 'Letztes Review', 'key_findings' => 'Wichtige Findings', 'accepted_risks' => 'Akzeptierte Risiken', + 'evidence_proof' => 'Evidence-Nachweis', 'published' => 'Veröffentlicht', 'review_pack' => 'Review-Pack', 'open_latest_review' => 'Letztes Review öffnen', 'download_review_pack' => 'Review-Pack herunterladen', + 'download_current_review_pack' => 'Aktuelles Review-Pack herunterladen', 'no_entitled_tenants' => 'Keine berechtigten Tenants passen zu dieser Ansicht', 'clear_filters_description' => 'Löschen Sie die aktuellen Filter, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.', 'adjust_filters_description' => 'Passen Sie die Filter an, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.', @@ -154,8 +156,28 @@ 'accepted_risks_need_follow_up' => ':warnings akzeptierte Risiken benötigen Governance-Nacharbeit (:total gesamt).', 'accepted_risks_governed' => ':count akzeptierte Risiken sind governed.', 'accepted_risks_on_record' => ':count akzeptierte Risiken sind erfasst.', + 'accepted_risk_accountable' => 'Verantwortlich: :name.', + 'accepted_risk_accountable_until' => 'Verantwortlich: :name. Erneute Prüfung bis :date.', + 'accepted_risk_reason' => 'Begründung: :reason.', + 'accepted_risk_partial_accountability' => 'Die Verantwortlichkeit ist teilweise erfasst; Review-Owner-Details sind nicht vollständig verfügbar.', 'unavailable' => 'Nicht verfügbar', 'available' => 'Verfügbar', + 'review_pack_available' => 'Aktuelles Review-Pack verfügbar', + 'no_current_review_pack' => 'Noch kein aktuelles Review-Pack verfügbar', + 'review_pack_access_unavailable' => 'Review-Pack-Zugriff ist für dieses Konto nicht verfügbar', + 'review_pack_unavailable' => 'Review-Pack ist noch nicht bereit', + 'review_pack_expired' => 'Review-Pack abgelaufen', + 'evidence_proof_available' => 'Nachweiszusammenfassung verfügbar', + 'evidence_proof_absent' => 'Noch keine Nachweiszusammenfassung verknüpft', + 'evidence_proof_access_unavailable' => 'Nachweiszugriff ist für dieses Konto nicht verfügbar', + 'evidence_proof_expired' => 'Nachweiszusammenfassung abgelaufen', + 'customer_review_pack_unavailable' => 'Das aktuelle Review-Pack kann aus diesem kundensicheren Flow nicht heruntergeladen werden.', + 'customer_review_pack_missing' => 'Diesem veröffentlichten Review ist noch kein aktuelles Review-Pack zugeordnet.', + 'customer_review_pack_not_ready' => 'Das zugeordnete Review-Pack ist noch nicht für den Download bereit.', + 'customer_review_pack_expired' => 'Das zugeordnete Review-Pack ist abgelaufen.', + 'customer_review_pack_forbidden' => 'Dieses Konto kann das Review lesen, aber das aktuelle Review-Pack nicht herunterladen.', + 'released_governance_record' => 'Veröffentlichter Governance-Nachweis', + 'released_governance_record_available' => 'Dieses veröffentlichte Review ist für kundensichere Governance-Nutzung verfügbar.', 'outcome_summary' => 'Ergebniszusammenfassung', 'review' => 'Review', 'review_date' => 'Review-Datum', diff --git a/apps/platform/lang/en/localization.php b/apps/platform/lang/en/localization.php index 45928412..fc3273fd 100644 --- a/apps/platform/lang/en/localization.php +++ b/apps/platform/lang/en/localization.php @@ -138,10 +138,12 @@ 'latest_review' => 'Latest review', 'key_findings' => 'Key findings', 'accepted_risks' => 'Accepted risks', + 'evidence_proof' => 'Evidence proof', 'published' => 'Published', 'review_pack' => 'Review pack', 'open_latest_review' => 'Open latest review', 'download_review_pack' => 'Download review pack', + 'download_current_review_pack' => 'Download current review pack', 'no_entitled_tenants' => 'No entitled tenants match this view', 'clear_filters_description' => 'Clear the current filters to return to the full customer review workspace for your entitled tenants.', 'adjust_filters_description' => 'Adjust filters to return to the full customer review workspace for your entitled tenants.', @@ -154,8 +156,28 @@ 'accepted_risks_need_follow_up' => ':warnings accepted risks need governance follow-up (:total total).', 'accepted_risks_governed' => ':count accepted risks are governed.', 'accepted_risks_on_record' => ':count accepted risks are on record.', + 'accepted_risk_accountable' => 'Accountable: :name.', + 'accepted_risk_accountable_until' => 'Accountable: :name. Re-review by :date.', + 'accepted_risk_reason' => 'Reason: :reason.', + 'accepted_risk_partial_accountability' => 'Accountability is partially recorded; review owner details are not fully available.', 'unavailable' => 'Unavailable', 'available' => 'Available', + 'review_pack_available' => 'Current review pack available', + 'no_current_review_pack' => 'No current review pack available yet', + 'review_pack_access_unavailable' => 'Review pack access is unavailable for this actor', + 'review_pack_unavailable' => 'Review pack is not ready yet', + 'review_pack_expired' => 'Review pack expired', + 'evidence_proof_available' => 'Proof summary available', + 'evidence_proof_absent' => 'No proof summary linked yet', + 'evidence_proof_access_unavailable' => 'Proof access is unavailable for this actor', + 'evidence_proof_expired' => 'Proof summary expired', + 'customer_review_pack_unavailable' => 'The current review pack cannot be downloaded from this customer-safe flow.', + 'customer_review_pack_missing' => 'No current review pack is attached to this released review yet.', + 'customer_review_pack_not_ready' => 'The attached review pack is not ready for download yet.', + 'customer_review_pack_expired' => 'The attached review pack has expired.', + 'customer_review_pack_forbidden' => 'This account can read the review but cannot download the current review pack.', + 'released_governance_record' => 'Released governance record', + 'released_governance_record_available' => 'This released review is available for customer-safe governance consumption.', 'outcome_summary' => 'Outcome summary', 'review' => 'Review', 'review_date' => 'Review date', diff --git a/apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php b/apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php index ff3a1169..2361e877 100644 --- a/apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php +++ b/apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php @@ -10,6 +10,7 @@ $operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : []; $compressedOutcome = is_array($state['compressed_outcome'] ?? null) ? $state['compressed_outcome'] : []; $reasonSemantics = is_array($state['reason_semantics'] ?? null) ? $state['reason_semantics'] : []; + $customerWorkspaceMode = (bool) ($state['customer_workspace_mode'] ?? false); $decisionDirection = is_string($compressedOutcome['decisionDirection'] ?? null) ? trim((string) $compressedOutcome['decisionDirection']) : null; @@ -110,14 +111,20 @@ $description = is_string($link['description'] ?? null) ? $link['description'] : null; @endphp - @continue($title === null || $label === null || $url === null) + @continue($title === null || $label === null)
      {{ $title }}
      - - {{ $label }} - + @if ($url !== null) + + {{ $label }} + + @else + + {{ __('localization.review.unavailable') }} + + @endif
      @if ($description !== null && trim($description) !== '') @@ -130,9 +137,15 @@ @endif
      -
      {{ __('localization.review.publication_readiness') }}
      +
      + {{ $customerWorkspaceMode ? __('localization.review.released_governance_record') : __('localization.review.publication_readiness') }} +
      - @if ($publishBlockers === [] && $decisionDirection === 'publishable') + @if ($customerWorkspaceMode) +
      + {{ __('localization.review.released_governance_record_available') }} +
      + @elseif ($publishBlockers === [] && $decisionDirection === 'publishable')
      {{ __('localization.review.ready_for_publication') }}
      diff --git a/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php b/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php index 12925053..7ee3262f 100644 --- a/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php +++ b/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php @@ -57,7 +57,7 @@ Storage::disk('exports')->put('review-packs/customer-review-workspace-smoke.zip', 'PK-test'); - ReviewPack::factory()->ready()->create([ + $pack = ReviewPack::factory()->ready()->create([ 'tenant_id' => (int) $tenantPublished->getKey(), 'workspace_id' => (int) $tenantPublished->workspace_id, 'tenant_review_id' => (int) $publishedReview->getKey(), @@ -67,6 +67,8 @@ 'file_disk' => 'exports', ]); + $publishedReview->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); + $this->actingAs($user)->withSession([ WorkspaceContext::SESSION_KEY => (int) $tenantPublished->workspace_id, WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [ @@ -83,6 +85,8 @@ ->waitForText('Customer-safe review workspace') ->assertSee('Clear filters') ->assertSee('Open latest review') + ->assertSee('Current review pack available') + ->assertSee('Proof summary available') ->assertDontSee('Publish review') ->assertDontSee('Refresh review') ->click('Clear filters') @@ -90,6 +94,8 @@ ->assertSee('No published review available yet') ->click('Open latest review') ->waitForText('Outcome summary') + ->assertSee('Download current review pack') + ->assertSee('Released governance record') ->assertDontSee('Publish review') ->assertDontSee('Refresh review') ->assertDontSee('Create next review') @@ -97,4 +103,4 @@ ->assertDontSee('Archive review') ->assertNoJavaScriptErrors() ->assertNoConsoleLogs(); -}); \ No newline at end of file +}); diff --git a/apps/platform/tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php b/apps/platform/tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php index 285f03fb..7f146711 100644 --- a/apps/platform/tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php +++ b/apps/platform/tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php @@ -2,7 +2,10 @@ declare(strict_types=1); +use App\Filament\Pages\Reviews\CustomerReviewWorkspace; +use App\Filament\Resources\EvidenceSnapshotResource; use App\Models\AuditLog; +use App\Models\EvidenceSnapshot; use App\Support\Audit\AuditActionId; use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceSnapshotStatus; @@ -31,3 +34,32 @@ ->and(AuditLog::query()->where('action', AuditActionId::EvidenceSnapshotExpired->value)->exists())->toBeTrue() ->and(data_get($expiredAudit?->metadata, 'reason'))->toBe('Evidence basis is obsolete.'); }); + +it('records audit entries when customer review proof is opened explicitly', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $snapshot = EvidenceSnapshot::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => EvidenceSnapshotStatus::Active->value, + 'completeness_state' => EvidenceCompletenessState::Complete->value, + 'summary' => ['finding_count' => 1], + 'generated_at' => now(), + ]); + + $this->actingAs($user) + ->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'tenant').'?'.http_build_query([ + 'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE, + ])) + ->assertOk(); + + $audit = AuditLog::query() + ->where('action', AuditActionId::EvidenceSnapshotOpened->value) + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull() + ->and($audit?->resource_type)->toBe('evidence_snapshot') + ->and(data_get($audit?->metadata, 'evidence_snapshot_id'))->toBe((int) $snapshot->getKey()) + ->and(data_get($audit?->metadata, 'source_surface'))->toBe(CustomerReviewWorkspace::SOURCE_SURFACE); +}); diff --git a/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php b/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php index 38b69706..b75bf9d2 100644 --- a/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php +++ b/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php @@ -5,6 +5,7 @@ use App\Filament\Resources\EvidenceSnapshotResource; use App\Filament\Resources\EvidenceSnapshotResource\Pages\ListEvidenceSnapshots; use App\Filament\Resources\EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot; +use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Jobs\GenerateEvidenceSnapshotJob; use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshotItem; @@ -419,6 +420,55 @@ function suspendEvidenceSnapshotWorkspace(Tenant $tenant): void ->assertSeeText('Copy JSON'); }); +it('hides evidence refresh, expiry, operation, fingerprint, and raw json in the customer review proof flow', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + $run = OperationRun::factory()->forTenant($tenant)->create(); + + $snapshot = EvidenceSnapshot::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'operation_run_id' => (int) $run->getKey(), + 'status' => EvidenceSnapshotStatus::Active->value, + 'completeness_state' => EvidenceCompletenessState::Complete->value, + 'summary' => ['finding_count' => 1], + 'fingerprint' => hash('sha256', 'customer-proof-flow'), + 'generated_at' => now(), + ]); + + EvidenceSnapshotItem::query()->create([ + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'dimension_key' => 'findings_summary', + 'state' => EvidenceCompletenessState::Complete->value, + 'required' => true, + 'source_kind' => 'model_summary', + 'summary_payload' => ['count' => 1, 'open_count' => 0], + 'sort_order' => 10, + ]); + + $this->actingAs($user) + ->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'tenant').'?'.http_build_query([ + 'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE, + ])) + ->assertOk() + ->assertSee('Evidence dimensions') + ->assertDontSee('Open the latest evidence refresh operation.') + ->assertDontSee('customer-proof-flow') + ->assertDontSee('Raw summary JSON') + ->assertDontSee('Copy JSON'); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::withQueryParams(['source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE]) + ->actingAs($user) + ->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()]) + ->assertActionDoesNotExist('refresh_evidence') + ->assertActionDoesNotExist('expire_snapshot'); +}); + it('hides expire actions for expired snapshots on list and detail surfaces', function (): void { $tenant = Tenant::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); diff --git a/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php b/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php index 1389f2f2..8c3948da 100644 --- a/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php +++ b/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php @@ -4,6 +4,7 @@ use App\Models\ReviewPack; use App\Models\AuditLog; +use App\Models\OperationRun; use App\Models\PlatformUser; use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\ReviewPackService; @@ -67,6 +68,8 @@ function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void $signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [ 'source_surface' => 'customer_review_workspace', ]); + $packCount = ReviewPack::query()->count(); + $operationRunCount = OperationRun::query()->count(); $response = $this->actingAs($user)->get($signedUrl); @@ -82,7 +85,9 @@ function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void expect($audit)->not->toBeNull() ->and($audit?->resource_type)->toBe('review_pack') ->and(data_get($audit?->metadata, 'review_pack_id'))->toBe((int) $pack->getKey()) - ->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace'); + ->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace') + ->and(ReviewPack::query()->count())->toBe($packCount) + ->and(OperationRun::query()->count())->toBe($operationRunCount); }); it('keeps ready pack downloads available while the workspace is suspended read-only', function (): void { diff --git a/apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php b/apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php index bbcffb8f..eb5a6266 100644 --- a/apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php +++ b/apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Resources\ReviewPackResource; use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks; use App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack; @@ -644,6 +645,40 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot ->assertActionVisible('regenerate'); }); +it('hides regenerate and raw pack diagnostics in the customer review pack flow', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + + $pack = ReviewPack::factory()->ready()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'initiated_by_user_id' => (int) $user->getKey(), + 'sha256' => hash('sha256', 'customer-pack-flow'), + 'fingerprint' => hash('sha256', 'customer-pack-fingerprint'), + ]); + + $this->actingAs($user) + ->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'tenant').'?'.http_build_query([ + 'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE, + ])) + ->assertOk() + ->assertSee('Outcome summary') + ->assertDontSee('Regenerate') + ->assertDontSee('SHA-256') + ->assertDontSee('Fingerprint') + ->assertDontSee('Include PII') + ->assertDontSee('Include operations'); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::withQueryParams(['source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE]) + ->actingAs($user) + ->test(ViewReviewPack::class, ['record' => $pack->getKey()]) + ->assertActionVisible('download') + ->assertActionDoesNotExist('regenerate'); +}); + // ─── Non-Member Access ─────────────────────────────────────── it('returns 404 for non-members on list page', function (): void { diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php index 6bf72b60..312bbe54 100644 --- a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php @@ -3,10 +3,12 @@ declare(strict_types=1); use App\Filament\Pages\Reviews\CustomerReviewWorkspace; +use App\Models\AuditLog; use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Support\Audit\AuditActionId; use App\Support\Workspaces\WorkspaceContext; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -46,6 +48,16 @@ ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->get(CustomerReviewWorkspace::getUrl(panel: 'admin')) ->assertOk(); + + $audit = AuditLog::query() + ->where('action', AuditActionId::CustomerReviewWorkspaceOpened->value) + ->latest('id') + ->first(); + + expect($audit)->not->toBeNull() + ->and($audit?->resource_type)->toBe('customer_review_workspace') + ->and(data_get($audit?->metadata, 'source_surface'))->toBe(CustomerReviewWorkspace::SOURCE_SURFACE) + ->and(data_get($audit?->metadata, 'entitled_tenant_count'))->toBe(1); }); it('returns 404 for explicit out-of-scope tenant targeting on the customer review workspace', function (): void { @@ -63,4 +75,4 @@ ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantAllowed->workspace_id]) ->get(CustomerReviewWorkspace::getUrl(panel: 'admin').'?tenant='.(string) $tenantDenied->getKey()) ->assertNotFound(); -}); \ No newline at end of file +}); diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php index ad752915..ecc94f4e 100644 --- a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php @@ -136,17 +136,32 @@ 'published_by_user_id' => (int) $user->getKey(), ])->save(); + Storage::disk('exports')->put('review-packs/customer-workspace-detail-download.zip', 'PK-test'); + + $pack = ReviewPack::factory()->ready()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'file_path' => 'review-packs/customer-workspace-detail-download.zip', + 'file_disk' => 'exports', + ]); + + $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); + setTenantPanelContext($tenant); Livewire::withQueryParams([CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1]) ->actingAs($user) ->test(ViewTenantReview::class, ['record' => $review->getKey()]) ->assertSee('Outcome summary') + ->assertActionVisible('download_current_review_pack') ->assertActionDoesNotExist('publish_review') ->assertActionDoesNotExist('refresh_review') ->assertActionDoesNotExist('create_next_review') ->assertActionDoesNotExist('export_executive_pack') - ->assertActionHidden('archive_review'); + ->assertActionDoesNotExist('archive_review'); $audit = AuditLog::query() ->where('action', AuditActionId::TenantReviewOpened->value) @@ -157,4 +172,4 @@ ->and($audit?->resource_type)->toBe('tenant_review') ->and(data_get($audit?->metadata, 'review_id'))->toBe((int) $review->getKey()) ->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace'); -}); \ No newline at end of file +}); diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php index 7cd1a4a4..11d9a151 100644 --- a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php @@ -6,12 +6,15 @@ use App\Models\PlatformUser; use App\Models\ReviewPack; use App\Models\Tenant; +use App\Models\User; use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Settings\SettingsWriter; +use App\Support\Auth\Capabilities; use App\Support\Auth\PlatformCapabilities; use App\Support\TenantReviewStatus; use App\Support\Workspaces\WorkspaceContext; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Gate; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -66,7 +69,7 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void ->test(CustomerReviewWorkspace::class) ->assertTableActionVisible('open_latest_review', $tenant) ->assertTableActionVisible('download_review_pack', $tenant) - ->assertSee('Available'); + ->assertSee('Current review pack available'); }); it('keeps customer review workspace and pack actions visible while suspended read-only', function (): void { @@ -104,7 +107,7 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void ->test(CustomerReviewWorkspace::class) ->assertTableActionVisible('open_latest_review', $tenant) ->assertTableActionVisible('download_review_pack', $tenant) - ->assertSee('Available'); + ->assertSee('Current review pack available'); }); it('shows an unavailable pack state and hides the download action when no current review pack exists', function (): void { @@ -128,7 +131,52 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void ->test(CustomerReviewWorkspace::class) ->assertTableActionVisible('open_latest_review', $tenant) ->assertTableActionHidden('download_review_pack', $tenant) - ->assertSee('Unavailable'); + ->assertSee('No current review pack available yet'); +}); + +it('distinguishes expired and capability-blocked review-pack states on the workspace', function (): void { + $expiredTenant = Tenant::factory()->create(['name' => 'Expired Pack Tenant']); + [$user, $expiredTenant] = createUserWithTenant(tenant: $expiredTenant, role: 'readonly'); + $blockedTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $expiredTenant->workspace_id, + 'name' => 'Blocked Pack Tenant', + ]); + createUserWithTenant(tenant: $blockedTenant, user: $user, role: 'readonly'); + + foreach ([$expiredTenant, $blockedTenant] as $tenant) { + $snapshot = seedTenantReviewEvidence($tenant); + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $review->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $pack = ReviewPack::factory()->ready()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'expires_at' => $tenant->is($expiredTenant) ? now()->subDay() : now()->addDay(), + ]); + + $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); + } + + Gate::define(Capabilities::REVIEW_PACK_VIEW, fn (User $actor, Tenant $tenant): bool => ! $tenant->is($blockedTenant)); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $expiredTenant->workspace_id); + + Livewire::actingAs($user) + ->test(CustomerReviewWorkspace::class) + ->assertCanSeeTableRecords([$expiredTenant->fresh(), $blockedTenant->fresh()]) + ->assertSee('Review pack expired') + ->assertSee('Review pack access is unavailable for this actor') + ->assertTableActionHidden('download_review_pack', $expiredTenant) + ->assertTableActionHidden('download_review_pack', $blockedTenant); }); it('hides review and pack actions for tenants without a published review', function (): void { diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php index d555c0db..c8c27967 100644 --- a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php @@ -4,6 +4,8 @@ use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Resources\TenantReviewResource; +use App\Models\Finding; +use App\Models\FindingException; use App\Models\Tenant; use App\Models\User; use App\Support\TenantReviewStatus; @@ -140,6 +142,59 @@ ->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $publishedReview->fresh()], $tenantPublished), false); }); +it('summarizes accepted-risk accountability and evidence proof availability in customer-safe workspace rows', function (): void { + $tenant = Tenant::factory()->create(['name' => 'Governed Tenant']); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + $owner = User::factory()->create(['name' => 'Risk Owner']); + $snapshot = seedTenantReviewEvidence($tenant); + + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $review->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + 'summary' => array_replace_recursive(is_array($review->summary) ? $review->summary : [], [ + 'risk_acceptance' => [ + 'status_marked_count' => 1, + 'valid_governed_count' => 1, + 'warning_count' => 0, + ], + ]), + ])->save(); + + $finding = Finding::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + FindingException::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_id' => (int) $finding->getKey(), + 'status' => FindingException::STATUS_ACTIVE, + 'current_validity_state' => FindingException::VALIDITY_VALID, + 'requested_by_user_id' => (int) $user->getKey(), + 'request_reason' => 'Vendor patch window accepted by the customer.', + 'owner_user_id' => (int) $owner->getKey(), + 'approved_by_user_id' => (int) $owner->getKey(), + 'requested_at' => now()->subDays(2), + 'approved_at' => now()->subDay(), + 'effective_from' => now()->subDay(), + 'review_due_at' => now()->addDays(14), + ]); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + Livewire::actingAs($user) + ->test(CustomerReviewWorkspace::class) + ->assertCanSeeTableRecords([$tenant->fresh()]) + ->assertSee('1 accepted risks are governed. Accountable: Risk Owner. Re-review by') + ->assertSee('Reason: Vendor patch window accepted by the customer.') + ->assertSee('Proof summary available'); +}); + it('defaults the customer review workspace to the remembered tenant when tenant context is available', function (): void { $tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']); [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly'); @@ -219,4 +274,4 @@ ->filterTable('tenant_id', (string) $tenantA->getKey()) ->assertCanSeeTableRecords([$tenantA->fresh()]) ->assertCanNotSeeTableRecords([$tenantB->fresh()]); -}); \ No newline at end of file +}); diff --git a/apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php b/apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php index 7ea6607b..1414a3f7 100644 --- a/apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php +++ b/apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php @@ -3,8 +3,10 @@ declare(strict_types=1); use App\Filament\Pages\Reviews\ReviewRegister; +use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Resources\TenantReviewResource; use App\Models\Tenant; +use App\Support\OperationRunLinks; use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; use App\Support\Ui\GovernanceArtifactTruth\SurfaceCompressionContext; @@ -62,3 +64,35 @@ ->assertSee($registerOutcome?->primaryReason ?? '') ->assertSee($explanation?->nextActionText ?? ''); }); + +it('keeps customer-workspace review detail customer-readable by hiding internal reason ownership and fingerprints', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + + $snapshot = seedTenantReviewEvidence($tenant); + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $review->forceFill([ + 'status' => 'published', + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + expect($review->operation_run_id)->not->toBeNull(); + + setTenantPanelContext($tenant); + + $this->actingAs($user) + ->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant).'?'.http_build_query([ + CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1, + ])) + ->assertOk() + ->assertSee('Released governance record') + ->assertSee('This released review is available for customer-safe governance consumption.') + ->assertSee('Evidence snapshot') + ->assertSee('source_surface=customer_review_workspace', false) + ->assertDontSee('Reason owner') + ->assertDontSee('Platform reason family') + ->assertDontSee('Fingerprint') + ->assertDontSee(OperationRunLinks::tenantlessView((int) $review->operation_run_id), false) + ->assertDontSee('Inspect the latest review composition or refresh run.'); +}); diff --git a/apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php b/apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php index f0f629cf..3e9ce62f 100644 --- a/apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php +++ b/apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php @@ -3,23 +3,31 @@ declare(strict_types=1); use App\Filament\Pages\Reviews\ReviewRegister; +use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Resources\TenantReviewResource; use App\Filament\Resources\TenantReviewResource\Pages\ListTenantReviews; use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview; +use App\Models\ReviewPack; use App\Models\Tenant; use App\Models\TenantReview; use App\Models\User; +use App\Support\TenantReviewStatus; use App\Services\TenantReviews\TenantReviewLifecycleService; use App\Support\Ui\GovernanceActions\GovernanceActionCatalog; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; use Filament\Actions\ActionGroup; +use Illuminate\Support\Facades\Storage; use Livewire\Features\SupportTesting\Testable; use Livewire\Livewire; use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures; uses(BuildsGovernanceArtifactTruthFixtures::class); +beforeEach(function (): void { + Storage::fake('exports'); +}); + function tenantReviewContractHeaderActions(Testable $component): array { $instance = $component->instance(); @@ -161,6 +169,54 @@ function tenantReviewContractHeaderActions(Testable $component): array ->and($groupLabels)->toBe(['More', 'Danger']); }); +it('uses the current review-pack download as the only customer-workspace detail header action', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + $snapshot = seedTenantReviewEvidence($tenant); + + $review = composeTenantReviewForTest($tenant, $user, $snapshot); + $review->forceFill([ + 'status' => TenantReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + Storage::disk('exports')->put('review-packs/customer-detail-primary.zip', 'PK-test'); + + $pack = ReviewPack::factory()->ready()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'file_path' => 'review-packs/customer-detail-primary.zip', + 'file_disk' => 'exports', + ]); + + $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); + + setTenantPanelContext($tenant); + + $component = Livewire::withQueryParams([CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1]) + ->actingAs($user) + ->test(ViewTenantReview::class, ['record' => $review->getKey()]) + ->assertActionVisible('download_current_review_pack') + ->assertActionEnabled('download_current_review_pack') + ->assertActionDoesNotExist('publish_review') + ->assertActionDoesNotExist('refresh_review') + ->assertActionDoesNotExist('create_next_review') + ->assertActionDoesNotExist('export_executive_pack') + ->assertActionDoesNotExist('archive_review'); + + $topLevelActionNames = collect(tenantReviewContractHeaderActions($component)) + ->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null) + ->filter() + ->values() + ->all(); + + expect($topLevelActionNames)->toBe(['download_current_review_pack']); +}); + it('shows publication truth and next-step guidance when a review is not yet publishable', function (): void { $tenant = Tenant::factory()->create(); [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); diff --git a/specs/258-customer-review-productization/checklists/requirements.md b/specs/258-customer-review-productization/checklists/requirements.md new file mode 100644 index 00000000..c8a0f589 --- /dev/null +++ b/specs/258-customer-review-productization/checklists/requirements.md @@ -0,0 +1,54 @@ +# Preparation Review Checklist: Customer Review Workspace Productization v1 + +**Purpose**: Validate repo-fit preparation quality after `spec.md`, `plan.md`, and `tasks.md` are complete +**Reviewed**: 2026-04-30 +**Feature**: [spec.md](../spec.md) +**Supporting artifacts**: [plan.md](../plan.md), [tasks.md](../tasks.md), [research.md](../research.md), [data-model.md](../data-model.md), [quickstart.md](../quickstart.md), [customer-review-productization.openapi.yaml](../contracts/customer-review-productization.openapi.yaml) + +## Candidate Fit + +- [x] The selected candidate still matches the active P0 queue in `docs/product/spec-candidates.md`, the current priority order in `docs/product/roadmap.md`, and the open-gap wording in `docs/product/implementation-ledger.md` +- [x] Existing `specs/` coverage was checked so this package stays a new productization follow-up rather than a duplicate of Specs 249, 253, 254, 255, or 257 +- [x] The scope stays on the customer-review productization delta over the existing workspace and released-review detail flow instead of reopening review foundations +- [x] Broader baseline/control overlays and management-packaging follow-through are explicitly deferred rather than hidden inside this slice + +## Constitution Fit + +- [x] The package stays on the existing Filament v5 + Livewire v4 admin plane and does not introduce panel/provider-registration work beyond the current `bootstrap/providers.php` truth +- [x] No new persistence, customer identity plane, portal shell, authoring flow, publication engine, remediation flow, or destructive action surface is introduced +- [x] Workspace/tenant isolation and capability-first RBAC remain explicit, including `404` for non-members and optional capability gating only for secondary access paths +- [x] One dominant safe action per changed surface is explicitly described, with secondary proof affordances demoted out of peer header-action status +- [x] Global-search safety is preserved without introducing a new searchable resource or widening existing review/evidence discovery across tenant boundaries +- [x] Asset strategy remains unchanged; if later implementation unexpectedly registers assets, deployment still uses the existing `cd apps/platform && php artisan filament:assets` step + +## Artifact Consistency + +- [x] `spec.md`, `plan.md`, and `tasks.md` all target the same workspace-summary plus released-review-detail follow-up +- [x] The likely repo surfaces and plan structure match the current repository layout, including `apps/platform/lang` rather than a fictional app-local language directory +- [x] Tasks directly cover RBAC, auditability, disclosure hierarchy, localization, access/unavailable states, and global-search safety +- [x] Supporting artifacts exist, no unresolved template markers remain, and the package stays implementation-ready without touching application code + +## Test Governance + +- [x] Validation lanes remain explicitly bounded to `confidence` plus one existing `browser` smoke +- [x] The package reuses the existing reviews test family instead of creating a new heavy-governance or browser family +- [x] Reviewer proof commands remain explicit and minimal for the touched workspace, detail, pack, and proof surfaces +- [x] The close-out path records the review outcome, guardrail status, and any `document-in-feature` vs `follow-up-spec` decision inside the spec package + +## Notes + +- Reviewed after artifact alignment on 2026-04-30. +- This repository's preparation artifacts are intentionally implementation-oriented, so concrete routes, classes, affected surfaces, and validation commands are expected rather than treated as leakage. +- No application implementation was performed while preparing or reviewing this package. +- Implementation close-out on 2026-04-30 passed the focused feature checks, bounded browser smoke, and Pint. Audit gaps were handled with bounded additive action IDs for workspace entry and proof-open events; global-search and asset strategy remained unchanged. + +## Review Outcome + +- **Outcome**: `keep` +- **Reason**: The package remains the narrow customer-review productization follow-up, explicitly records the baseline/control deferral, aligns the detail-page action hierarchy, and adds direct task coverage for global-search safety. +- **Workflow result**: Ready for `/speckit.implement` after this preparation review. + +## Implementation Outcome + +- **Outcome**: `implemented` +- **Workflow result**: Ready for manual review after the implementation loop. No confirmed in-scope findings remain after the focused confidence checks, browser smoke, formatting, and post-implementation analysis. diff --git a/specs/258-customer-review-productization/contracts/customer-review-productization.openapi.yaml b/specs/258-customer-review-productization/contracts/customer-review-productization.openapi.yaml new file mode 100644 index 00000000..4e8d9672 --- /dev/null +++ b/specs/258-customer-review-productization/contracts/customer-review-productization.openapi.yaml @@ -0,0 +1,299 @@ +openapi: 3.0.3 +info: + title: TenantPilot Customer Review Workspace Productization v1 (Conceptual) + version: 0.1.0 + description: | + Conceptual contract for the customer-safe productization follow-up in Spec 258. + + NOTE: These paths describe existing admin and tenant-scoped routes reused by + the implementation. The schemas document expected derived page/view behavior + for planning purposes only; they do not require a new public REST API. +servers: + - url: / +paths: + /admin/reviews/workspace: + get: + summary: View the productized customer review workspace + description: | + Existing canonical admin-plane workspace page for customer-safe review + consumption. The route stays read-only and reuses current tenant review, + finding, evidence, review-pack, localization, RBAC, and audit truth. + parameters: + - in: query + name: tenant + required: false + schema: + type: string + description: | + Optional tenant prefilter using the existing tenant id or external id + pattern already accepted by the workspace page. + responses: + '200': + description: Workspace page rendered + content: + text/html: + schema: + type: string + application/json: + schema: + $ref: '#/components/schemas/CustomerReviewWorkspacePageModel' + '404': + description: Not found for non-members, actors without entitled tenants, or explicit out-of-scope tenant targeting + + /admin/t/{tenant}/reviews/{review}: + get: + summary: Open the released review detail from the customer review workspace + description: | + Existing tenant-scoped released-review detail route reused as the + secondary context surface from the workspace page. The customer-workspace + flow uses the existing `customer_workspace=1` query flag to keep the + detail read-only and customer-safe. + parameters: + - in: path + name: tenant + required: true + schema: + type: integer + - in: path + name: review + required: true + schema: + type: integer + - in: query + name: customer_workspace + required: false + schema: + type: boolean + description: Existing query-context flag that suppresses operator lifecycle actions on the detail surface. + responses: + '200': + description: Released review detail rendered + content: + text/html: + schema: + type: string + application/json: + schema: + $ref: '#/components/schemas/CustomerReviewDetailModel' + '403': + description: Forbidden for an in-scope actor missing the record-level review permission + '404': + description: Not found for non-members, tenant mismatches, or out-of-scope review targets + + /admin/t/{tenant}/evidence/{evidenceSnapshot}: + get: + summary: Open an evidence proof route from the customer review flow + description: | + Existing tenant-scoped evidence detail route reused only when the actor + explicitly asks for proof and has the required capability. + parameters: + - in: path + name: tenant + required: true + schema: + type: integer + - in: path + name: evidenceSnapshot + required: true + schema: + type: integer + - in: query + name: source_surface + required: false + schema: + type: string + description: Optional source-surface metadata if proof access is audited through the shared audit pipeline. + responses: + '200': + description: Evidence proof detail rendered + content: + text/html: + schema: + type: string + '403': + description: Forbidden for an in-scope actor missing evidence capability + '404': + description: Not found for non-members, mismatched tenant scope, or unavailable proof targets + + /admin/review-packs/{reviewPack}/download: + get: + summary: Download the current review pack + description: | + Existing signed download route reused by the productized customer review + flow. The pack must already exist, be ready, and not be expired. + parameters: + - in: path + name: reviewPack + required: true + schema: + type: integer + - in: query + name: source_surface + required: false + schema: + type: string + description: Existing download metadata hook used by the shared audit path. + responses: + '200': + description: Review pack download stream + content: + application/zip: + schema: + type: string + format: binary + '403': + description: Forbidden because of missing signature or invalid signed URL + '404': + description: Review pack not found, not ready, expired, or out of accessible tenant scope + +components: + schemas: + CustomerReviewWorkspacePageModel: + type: object + required: + - workspace_id + - entries + properties: + workspace_id: + type: integer + tenant_filter_id: + type: integer + nullable: true + entries: + type: array + items: + $ref: '#/components/schemas/CustomerReviewWorkspaceEntry' + empty_state_message: + type: string + nullable: true + audit_expectation: + type: string + nullable: true + description: | + Planning-only note describing whether workspace-open auditing is + already covered or requires a bounded shared-audit extension. + + CustomerReviewWorkspaceEntry: + type: object + required: + - tenant_id + - tenant_name + - review_access + - review_pack_access + - evidence_proof_access + properties: + tenant_id: + type: integer + tenant_name: + type: string + latest_published_review_id: + type: integer + nullable: true + latest_review_published_at: + type: string + format: date-time + nullable: true + outcome_summary: + type: string + nullable: true + findings_summary: + type: string + nullable: true + accepted_risk_accountability_summary: + $ref: '#/components/schemas/AcceptedRiskAccountabilitySummary' + review_access: + $ref: '#/components/schemas/AccessState' + review_pack_access: + $ref: '#/components/schemas/AccessState' + evidence_proof_access: + $ref: '#/components/schemas/AccessState' + redaction_note: + type: string + nullable: true + absence_note: + type: string + nullable: true + + CustomerReviewDetailModel: + type: object + required: + - review_id + - tenant_id + - launched_from_customer_workspace + - operator_actions_hidden + properties: + review_id: + type: integer + tenant_id: + type: integer + launched_from_customer_workspace: + type: boolean + operator_actions_hidden: + type: boolean + narrative_outcome_summary: + type: string + nullable: true + findings_summary: + type: string + nullable: true + accepted_risk_accountability_summary: + $ref: '#/components/schemas/AcceptedRiskAccountabilitySummary' + evidence_summary: + type: string + nullable: true + review_pack_access: + $ref: '#/components/schemas/AccessState' + evidence_proof_access: + $ref: '#/components/schemas/AccessState' + secondary_diagnostics_collapsed: + type: boolean + nullable: true + + AcceptedRiskAccountabilitySummary: + type: object + nullable: true + properties: + summary_text: + type: string + accountable_party: + type: string + nullable: true + decision_reason: + type: string + nullable: true + review_due_at: + type: string + format: date-time + nullable: true + expires_at: + type: string + format: date-time + nullable: true + completeness_note: + type: string + nullable: true + + AccessState: + type: object + required: + - state + properties: + state: + type: string + enum: + - available + - absent + - unavailable + - expired + - redacted + - partial + message: + type: string + nullable: true + url: + type: string + nullable: true + audit_action_id: + type: string + nullable: true + description: Existing or bounded-additive shared audit action id for the explicit access moment. \ No newline at end of file diff --git a/specs/258-customer-review-productization/data-model.md b/specs/258-customer-review-productization/data-model.md new file mode 100644 index 00000000..1a7a9508 --- /dev/null +++ b/specs/258-customer-review-productization/data-model.md @@ -0,0 +1,273 @@ +# Data Model — Customer Review Workspace Productization v1 + +**Spec**: [spec.md](spec.md) + +No new persisted tables, projections, or customer-review entities are required for this follow-up. The feature reuses current tenant-owned review, finding-exception, evidence, review-pack, membership, and audit truth, then tightens the derived workspace and detail presentation contracts. + +## Persisted Truth Reused + +### Workspace / Tenant Entitlement Context + +**Purpose**: Establish the active workspace boundary and the entitled tenant set before any workspace rows, proof links, or review detail routes are composed. + +**Persisted carriers**: +- existing workspace membership records +- existing tenant membership pivot rows and role assignments +- existing capability registry and role-capability map + +**Relevant fields / contracts**: +- `workspace_id` +- `tenant_id` +- tenant membership role +- capability grants derived from [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php) +- current workspace and remembered tenant context from the existing workspace context/session model + +**Validation rules**: +- current actor must be a member of the current workspace or the route resolves as not found +- workspace rows and explicit tenant filters may only resolve for entitled tenants in that current workspace +- out-of-scope tenant targets remain `404` and must not leak draft/review existence + +### TenantReview + +**Purpose**: Canonical source for the released governance record, current outcome summary, findings summary, accepted-risk summary, proof pointers, and review-detail inspect target. + +**Persisted carrier**: existing `tenant_reviews` rows via [../../apps/platform/app/Models/TenantReview.php](../../apps/platform/app/Models/TenantReview.php) + +**Relevant fields / relationships**: +- `id` +- `workspace_id` +- `tenant_id` +- `status` +- `generated_at` +- `published_at` +- `summary` +- `evidence_snapshot_id` +- `current_export_review_pack_id` +- `published_by_user_id` +- `tenant` +- `evidenceSnapshot` +- `currentExportReviewPack` +- `sections` + +**Embedded summary payload currently reused**: +- `finding_count` +- `finding_outcomes` +- `risk_acceptance.status_marked_count` +- `risk_acceptance.valid_governed_count` +- `risk_acceptance.warning_count` +- `publish_blockers` + +**Validation / usage rules**: +- the workspace default path continues to use the latest published review per entitled tenant only +- internal-only review states remain off the customer-safe default path +- the customer-workspace drilldown stays on the existing review detail route under the existing query-context flag +- productization may refine how summary data is explained, but it must not move that truth into a new stored model + +### FindingException + +**Purpose**: Existing accepted-risk and accountability truth used to explain who accepted risk, why it is on record, and whether it needs follow-up. + +**Persisted carrier**: existing `finding_exceptions` rows via [../../apps/platform/app/Models/FindingException.php](../../apps/platform/app/Models/FindingException.php) + +**Relevant fields / relationships**: +- `id` +- `workspace_id` +- `tenant_id` +- `finding_id` +- `status` +- `current_validity_state` +- `requested_at` +- `approved_at` +- `effective_from` +- `expires_at` +- `review_due_at` +- `owner_user_id` +- `approved_by_user_id` +- `current_decision_id` +- `evidence_summary` +- `owner` +- `approver` +- `currentDecision` +- `evidenceReferences` + +**Validation / usage rules**: +- accountability summaries should derive from existing owner/approver/current-decision truth where present +- missing accountable-person or accountable-role truth must surface as partial/unavailable disclosure, not fabricated customer-safe copy +- accepted-risk visibility remains read-only in this slice; no edit, renew, revoke, or approval behavior moves into the customer-safe path + +### EvidenceSnapshot + +**Purpose**: Existing proof artifact for evidence freshness, completeness, and optional supporting detail reached only after explicit user intent. + +**Persisted carrier**: existing `evidence_snapshots` rows via [../../apps/platform/app/Models/EvidenceSnapshot.php](../../apps/platform/app/Models/EvidenceSnapshot.php) + +**Relevant fields / relationships**: +- `id` +- `workspace_id` +- `tenant_id` +- `status` +- `completeness_state` +- `generated_at` +- `expires_at` +- `summary` +- `items` + +**Validation / usage rules**: +- evidence proof remains optional, lower-priority, and capability-gated by the current evidence-view path +- raw payloads and unrestricted diagnostics remain out of the default-visible workspace and review detail path +- if implementation adds explicit proof-access auditing, it should stay on the shared audit pipeline + +### ReviewPack + +**Purpose**: Existing packaged governance artifact for current downloadable review output. + +**Persisted carrier**: existing `review_packs` rows via [../../apps/platform/app/Models/ReviewPack.php](../../apps/platform/app/Models/ReviewPack.php) + +**Relevant fields / relationships**: +- `id` +- `workspace_id` +- `tenant_id` +- `tenant_review_id` +- `status` +- `generated_at` +- `expires_at` +- `summary` +- `file_path` +- `file_disk` +- `sha256` +- `operation_run_id` +- `tenantReview` +- `evidenceSnapshot` + +**Validation / usage rules**: +- only current ready, unexpired packs remain available in the customer-safe flow +- review-pack access continues to use the existing signed download route and current capability check +- the feature must not surface generate/regenerate flows, even when a pack is unavailable + +### Audit Log Event Family + +**Purpose**: Existing auditable truth for explicit customer-review consumption moments. + +**Persisted carrier**: existing `audit_logs` rows via [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) + +**Relevant current action IDs**: +- `tenant_review.opened` +- `review_pack.downloaded` + +**Potential bounded extensions only if implementation confirms a gap**: +- workspace access open event for the customer review workspace route +- evidence proof access open event for proof routes launched from the customer review flow + +**Validation / usage rules**: +- auditable access remains on the shared audit path only +- no new audit store or mirror analytics stream is justified +- workspace, tenant, source-surface, and artifact identifiers stay in stable audit metadata when a new access moment is added + +## Derived Read Models + +### CustomerReviewWorkspaceEntry + +**Purpose**: Derived row-level presentation contract for one entitled tenant on the existing workspace page. + +**Persistence**: none; computed at request time + +**Fields**: +- `workspace_id` +- `tenant_id` +- `tenant_name` +- `latest_published_review_id` (nullable) +- `latest_review_published_at` (nullable) +- `outcome_summary` +- `findings_summary` +- `accepted_risk_accountability_summary` +- `evidence_proof_state` +- `review_pack_state` +- `primary_review_url` (nullable) +- `review_pack_download_url` (nullable) +- `proof_detail_url` (nullable) +- `absence_note` (nullable) +- `unavailable_note` (nullable) +- `redaction_note` (nullable) + +**Derivation rules**: +- exactly one derived entry exists per entitled tenant visible in the current workspace scope +- if a published review exists, the entry derives its customer-safe summary from that released record only +- if no published review exists, the entry surfaces an explicit absence note and omits deep links that depend on a released review +- if optional proof or pack access is blocked by capability or artifact state, the review remains readable while the secondary path becomes explicitly unavailable + +**Validation rules**: +- entries may only be built for entitled tenants in the active workspace +- `review_pack_download_url` is present only when a current pack exists and the actor can consume it +- `proof_detail_url` is present only when the actor can open the proof route +- raw payloads, unrestricted diagnostics, provider IDs, and copied support context are never part of the default entry model + +### CustomerReviewDetailPresentation + +**Purpose**: Derived section contract for the existing released-review detail page when it is launched from the customer review workspace. + +**Persistence**: none; computed from the existing review record and current query-context flag + +**Fields**: +- `review_id` +- `tenant_id` +- `launched_from_customer_workspace` (boolean) +- `narrative_outcome_summary` +- `findings_summary` +- `accepted_risk_accountability_summary` +- `evidence_summary` +- `proof_pointer_state` +- `review_pack_state` +- `operator_actions_hidden` (boolean) +- `secondary_diagnostics_collapsed` (boolean) + +**Derivation rules**: +- only the existing `customer_workspace` query context activates this productized secondary presentation mode +- the detail remains readable even when optional pack/evidence capabilities are absent +- management actions remain suppressed in this context + +**Validation rules**: +- this derived model must not create a second review detail route or a second stored summary object +- secondary proof and support detail remain lower-priority than the narrative governance record +- duplicate equal-priority summary blocks between workspace and detail should be removed or reduced + +### CustomerReviewPageState + +**Purpose**: Request/query/session-backed page state already required for tenant-prefilter, remembered scope, and launch context continuity. + +**Persistence**: request, URL query, and existing session-backed table state only + +**Fields**: +- `tenant` prefilter (nullable) +- remembered tenant id in workspace context (nullable) +- `customer_workspace` detail context flag (boolean on the detail route) +- navigation context metadata when launched from other canonical pages (nullable) + +**Validation rules**: +- explicit tenant prefilters must resolve to an entitled tenant or the request fails as not found +- any state required after Livewire interaction must remain hydrated via public/query/session-backed state +- no private property may own the control path for disclosure or filter restore + +## Derived Disclosure States + +This feature introduces no new persisted lifecycle or enum family. It does require explicit derived disclosure outcomes on existing surfaces: + +- `available`: the actor can open the review/proof/pack path now +- `absent`: the underlying released artifact does not exist for this tenant yet +- `unavailable`: the artifact exists conceptually but is not currently consumable because of capability, readiness, or redaction limits +- `expired`: the artifact exists and was previously consumable, but time-based or release-lifecycle rules now block access while the surface still needs to explain why +- `redacted`: the route or surface remains visible, but protected details stay hidden behind existing redaction rules +- `partial`: the governance record is readable, but accountability/proof detail is incomplete in current source truth + +These remain derived page semantics only and must not become stored status families. + +## State Transition Summary + +No new persisted lifecycle is added. Only derived surface transitions are expected: + +- workspace open -> entitled tenant rows or truthful empty/absence state +- remembered tenant or explicit tenant query -> tenant-prefiltered workspace view +- workspace row with released review -> existing review detail route available +- workspace row without released review -> explicit absence state and no review-open action +- released review detail with optional proof/pack capability missing -> review remains readable and secondary path becomes unavailable +- released review detail with an expired pack/proof artifact -> review remains readable and secondary path becomes explicitly expired +- explicit workspace/review/proof/pack consumption -> shared audit event when covered by the current audit registry or a bounded additive action ID \ No newline at end of file diff --git a/specs/258-customer-review-productization/plan.md b/specs/258-customer-review-productization/plan.md new file mode 100644 index 00000000..6055c4c0 --- /dev/null +++ b/specs/258-customer-review-productization/plan.md @@ -0,0 +1,301 @@ +# Implementation Plan: Customer Review Workspace Productization v1 + +**Branch**: `258-customer-review-productization` | **Date**: 2026-04-30 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from [spec.md](spec.md) + +## Summary + +Productize the existing customer review workspace into a calmer, customer-safe governance-of-record surface by tightening the current [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) page and the existing released-review drilldown in [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php). The implementation should reuse current review, finding, accepted-risk, evidence, review-pack, localization, RBAC, and audit truth rather than adding a portal shell, new persistence, or a second presentation framework. + +This is a bounded follow-up to Spec 249, not a fresh workspace foundation. Filament remains on Livewire v4 under v5, panel-provider registration stays where it is today in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new panel or provider work is planned, no new globally searchable scope is introduced, no destructive actions are in scope, and no new asset registration strategy is expected. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack services, capability helpers, localization copy, and workspace audit infrastructure +**Storage**: PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, `finding_exceptions`, memberships, and `audit_logs`; no new persistence planned +**Testing**: Pest v4 feature coverage plus one bounded browser smoke slice on the existing workspace flow +**Validation Lanes**: confidence, browser +**Target Platform**: Laravel monolith in `apps/platform`, existing admin plane only (`/admin` plus existing tenant-scoped `/admin/t/{tenant}` reuse) +**Project Type**: Web application (Laravel monolith with Filament pages/resources) +**Performance Goals**: keep workspace and detail rendering DB-only and scope-safe, reuse eager-loaded existing review/pack/evidence relations, and avoid any new Graph calls, queue starts, or heavy asset work on render +**Constraints**: no new page shell, no new persistence, no review publishing engine, no remediation flow, no new customer identity plane, no new global-search scope, no new heavy asset strategy, and no destructive action exposure +**Scale/Scope**: 1 existing workspace page, 1 existing released-review detail page, 2 existing proof/detail resources, 2 localization files, 1 shared audit pipeline, and the existing `tests/Feature/Reviews/*` plus 1 existing browser smoke + +## Likely Affected Repo Surfaces + +- [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) for calmer workspace copy, derived summary semantics, explicit access or absence states, and table action hierarchy. +- [../../apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php](../../apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php) for the page intro and disclosure framing. +- [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) for the released-review secondary-context contract, customer-workspace query-flag behavior, and audit handoff. +- [../../apps/platform/app/Filament/Resources/TenantReviewResource.php](../../apps/platform/app/Filament/Resources/TenantReviewResource.php) for existing released-review detail sections, proof links, and current pack/evidence affordances. +- [../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php) for workspace membership, entitled tenant scoping, and latest-published review composition. +- [../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php](../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php) plus `SurfaceCompressionContext` for outcome, freshness, and publication wording already used by review and pack surfaces. +- [../../apps/platform/app/Filament/Resources/ReviewPackResource.php](../../apps/platform/app/Filament/Resources/ReviewPackResource.php), [../../apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php](../../apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php), and [../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php](../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php) for current pack availability, safe deep links, and signed download auditing. +- [../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php](../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php) and [../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php](../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php) for proof-pointer routing and explicit unavailable states. +- [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) and [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) for auditable workspace access, review access, proof access, and pack download behavior through the shared audit path. +- [../../apps/platform/app/Services/Auth/RoleCapabilityMap.php](../../apps/platform/app/Services/Auth/RoleCapabilityMap.php) and [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php) for capability-first RBAC and workspace/tenant-safe omission rules. +- [../../apps/platform/lang/en/localization.php](../../apps/platform/lang/en/localization.php) and [../../apps/platform/lang/de/localization.php](../../apps/platform/lang/de/localization.php) for calmer customer-safe wording without introducing a second vocabulary system. +- [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php), [../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php](../../apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php), and [../../apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php](../../apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php) for the bounded proof surface already in the repo. + +## UI / Filament & Livewire Fit + +- Keep [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) as the canonical customer-safe landing surface. This follow-up productizes the existing page instead of adding a new page class, a new Resource, or a new panel. +- Keep [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) as the secondary context surface reached from the workspace via the existing `customer_workspace` query flag. The drilldown should deepen the governance record, not reopen operator lifecycle controls. +- Preserve the current Livewire-safe filter and remembered-tenant behavior already implemented on the workspace page. Any added state must remain public, query-backed, or session-backed; no private state should control postback-critical disclosure. +- Retain one dominant next action per surface. On the workspace page that remains `Open released review`; pack download or proof routes stay secondary and capability-gated. On the detail page, review-pack access remains the dominant safe action while evidence proof stays lower priority. +- Keep the entire feature in native Filament primitives plus the existing review/evidence shared seams. No custom shell, no heavy asset registration, and no new global-search scope are planned. + +## RBAC / Policy Fit + +- Workspace membership remains the first isolation boundary through the existing workspace context and `TenantReviewRegisterService::canAccessWorkspace(...)` path. +- Entitled-tenant composition remains capability-first: page entry and rows continue to derive from the current role-capability map and `TENANT_REVIEW_VIEW` path rather than new customer-only roles or raw role-string checks. +- Proof pointers and safe secondary actions continue to reuse existing gates: `REVIEW_PACK_VIEW` for current pack download, `EVIDENCE_VIEW` for proof detail, `TENANT_FINDINGS_VIEW` and `FINDING_EXCEPTION_VIEW` for deeper review content when surfaced, and existing policy checks on review/evidence resources. +- Non-members and explicit out-of-scope tenant targets remain `404`. Member actors who can read the review surface but lack an optional deep-link capability should still see the review with an explicit unavailable state for that optional path. +- No new panel, tenant plane, customer portal plane, or identity model is introduced. This remains an admin-plane follow-up only. + +## Audit / Logging Fit + +- Reuse [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) and [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) for all auditable moments. No new audit store, no telemetry sidecar, and no page-local logging subsystem are justified. +- Current review access from the customer-workspace drilldown already logs `TenantReviewOpened` with `source_surface=customer_review_workspace`, and current pack downloads already log `ReviewPackDownloaded` through the signed download route. +- Planning should explicitly account for two remaining audit moments required by this spec: workspace access itself and evidence-summary or proof access when the actor opens an explicit proof route from the customer-safe flow. If those moments are not already covered, the narrowest acceptable change is additive stable action IDs on the existing audit pipeline. +- Passive rendering should still avoid noisy event spam. The auditable boundary is explicit workspace entry or explicit artifact/proof consumption, not every Livewire repaint. + +## Data & Query Fit + +- Keep the base row query on the existing `customerWorkspaceTenantQuery(...)` seam in [../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php). This feature productizes the presentation contract over that query; it does not replace it with a new projection. +- Findings and accepted-risk summaries remain derived from the current `TenantReview.summary` payload already used by the workspace page, including `finding_count`, `finding_outcomes`, and `risk_acceptance` substructures. +- Accepted-risk accountability follow-through should reuse existing `FindingException` and current decision truth where that data already exists. Missing accountable-person or accountable-role truth must surface as explicit partial or unavailable disclosure, not invented copy. +- Evidence proof semantics should stay anchored to existing `EvidenceSnapshot`, related context entries, and `ArtifactTruthPresenter` output. The feature may reorder or reword disclosure, but it should not create a second evidence summary model. +- Access, absence, unavailable, expired, and redacted states remain derived UI or route-state semantics only. They must not become new persisted lifecycle fields or a new presentation enum family. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament +- **Shared-family relevance**: status messaging, evidence/report viewers, action links, navigation entry points, access-state messaging, and review-pack access affordances +- **State layers in scope**: page, detail, URL-query, table/session restore +- **Audience modes in scope**: customer/read-only, customer-admin, auditor-read-only, operator-MSP +- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third +- **Raw/support gating plan**: collapsed and capability-gated on reused detail/proof routes only +- **One-primary-action / duplicate-truth control**: `Open released review` remains the workspace primary action; review-pack access is the detail primary action; equal-priority duplicate summary blocks across workspace and detail are out of scope +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: standard-native-filament, shared-detail-family +- **Required tests or manual smoke**: functional-core, state-contract, bounded-browser-smoke +- **Exception path and spread control**: none planned; any new presenter/taxonomy/customer-shell proposal becomes exception-required drift +- **Active feature PR close-out entry**: Guardrail / Smoke Coverage + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `CustomerReviewWorkspace`, `ViewTenantReview`, `TenantReviewResource`, `ReviewPackResource`, `EvidenceSnapshotResource`, `TenantReviewRegisterService`, `ArtifactTruthPresenter`, `SurfaceCompressionContext`, `ActionSurfaceDeclaration`, `ReviewPackDownloadController`, `WorkspaceAuditLogger`, `AuditActionId`, and review localization copy +- **Shared abstractions reused**: `TenantReviewRegisterService`, `ArtifactTruthPresenter`, `SurfaceCompressionContext`, existing resource URL helpers, existing action-surface declarations, `ReviewPackService`, and the shared audit logger +- **New abstraction introduced? why?**: none planned. If implementation discovers a small copy or disclosure helper is needed, it should stay inside the existing review surface family instead of becoming a new reusable framework +- **Why the existing abstraction was sufficient or insufficient**: the repo already has the page, detail route, truth envelopes, pack download path, and audit seams; what is insufficient today is the product contract over those seams, not the underlying domain model +- **Bounded deviation / spread control**: none planned. This slice should tighten the current path rather than add a parallel customer-review language, mirror page, or publication layer + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no +- **Central contract reused**: `N/A` +- **Delegated UX behaviors**: `N/A` +- **Surface-owned behavior kept local**: read-only workspace and detail rendering only; any existing operation-run links remain secondary diagnostics on reused detail surfaces +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: `N/A` +- **Platform-core seams**: existing workspace, tenant, review, evidence, risk acceptance, review pack, and audit vocabulary only +- **Neutral platform terms / contracts preserved**: `workspace`, `tenant`, `review`, `evidence`, `review pack`, `accepted risk`, `proof`, and existing artifact-truth wording +- **Retained provider-specific semantics and why**: none new +- **Bounded extraction or follow-up path**: `N/A` + +## Constitution Check + +*GATE: Must pass before implementation preparation continues. Re-check after Phase 1 design artifacts.* + +- Inventory-first / snapshot truth: PASS. The slice consumes existing review, pack, and evidence artifacts as read-only truth. +- Read/write separation: PASS. No new create, publish, regenerate, refresh, remediation, or destructive flow is introduced. +- Graph contract path: PASS. No new Graph work or provider contract work is part of this slice. +- Deterministic capabilities: PASS. Existing capability registries and role maps remain authoritative. +- Workspace and tenant isolation: PASS. Workspace membership and tenant entitlement remain non-negotiable `404` boundaries. +- RBAC-UX plane separation: PASS. Everything stays in the existing `/admin` plane and current tenant-scoped detail routes. +- Destructive confirmation standard: PASS by non-use. Destructive actions are out of scope. +- Global search safety: PASS. No new globally searchable resource or search scope is added; any mention of search remains tenant-safe reuse only. +- OperationRun / Ops-UX: PASS by non-use. The productization slice starts no runs and changes no run lifecycle UX. +- Data minimization: PASS. Default-visible content remains decision-first; raw payloads and unrestricted diagnostics stay gated. +- Test governance (TEST-GOV-001): PASS. Planned proof stays in focused feature coverage plus one bounded browser smoke. +- Proportionality / no premature abstraction: PASS. The feature productizes existing surfaces instead of adding persistence, a shell, or a second presenter framework. +- Persisted truth (PERSIST-001): PASS. No new table, artifact, or cache is planned. +- Behavioral state (STATE-001): PASS. Access, absence, unavailable, expired, and redacted conditions remain derived presentation semantics. +- UI semantics / shared pattern first / Filament-native UI: PASS. Native Filament pages/resources and existing truth abstractions remain the default path. +- Provider boundary (PROV-001): PASS. No provider/platform seam widens. +- Filament / Laravel planning contract: PASS. Filament v5 stays on Livewire v4, provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new panel/provider work is planned, no new global-search scope is created, and asset handling stays unchanged (`cd apps/platform && php artisan filament:assets` remains deploy-only if future registered assets are ever added). + +**Gate evaluation**: PASS. + +- The feature stays in the existing admin plane and current workspace/tenant membership model. +- The canonical entry surface remains the existing customer review workspace, not a new shell. +- Existing truth seams are sufficient if implementation resists adding a mirror presenter or publication engine. + +**Post-design re-check**: PASS (design artifacts: [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), [contracts/customer-review-productization.openapi.yaml](contracts/customer-review-productization.openapi.yaml)). + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Feature for workspace rows, access/absence/unavailable states, navigation context, pack access, and audit metadata; Browser for one bounded end-to-end calm disclosure path on the existing workspace handoff +- **Affected validation lanes**: confidence, browser +- **Why this lane mix is the narrowest sufficient proof**: the repo already has the exact workspace feature family and a single smoke harness; expanding those files is cheaper and more honest than adding new browser families or generalized helpers +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` +- **Fixture / helper / factory / seed / context cost risks**: moderate but contained; reuse existing workspace membership, entitled-tenant, published review, finding-exception, evidence snapshot, review pack, and audit fixtures +- **Expensive defaults or shared helper growth introduced?**: no; any new helper should stay explicit and inside the reviews family +- **Heavy-family additions, promotions, or visibility changes**: none beyond the already-existing single browser smoke +- **Surface-class relief / special coverage rule**: standard-native-filament relief on the workspace page, shared-detail-family coverage on the released-review handoff +- **Closing validation and reviewer handoff**: rerun the focused commands above, verify customer-safe default visibility, verify `404` on out-of-scope tenant targeting, verify optional proof paths show explicit unavailable states instead of leaking content, and verify audit metadata stays on the shared logger path +- **Budget / baseline / trend follow-up**: none expected beyond small feature-local assertions in the existing reviews suite +- **Review-stop questions**: lane fit, hidden fixture growth, browser sprawl, duplicate-truth regressions, audit-gap drift +- **Escalation path**: `document-in-feature` for contained audit metadata placement notes; `reject-or-split` for any drift into new persistence, portal scope, or expanded browser coverage +- **Active feature PR close-out entry**: Guardrail / Smoke Coverage +- **Why no dedicated follow-up spec is needed**: this is already the bounded follow-up to Spec 249; remaining work stays inside this productization lane unless it tries to become publishing/remediation/portal scope + +## Rollout & Risk Controls + +- Keep the canonical entry surface on the existing workspace page and the canonical secondary surface on the existing released-review detail route. +- Keep all proof and packaged artifact flows on the existing tenant review, review-pack, and evidence routes. Do not add a new proof viewer or download endpoint. +- Treat missing accountability truth or missing proof availability as explicit partial or unavailable disclosure, never as fabricated customer-safe copy. +- Prefer localization-key updates in the existing review language namespace over page-local inline wording. +- Keep browser validation bounded to the existing smoke harness before considering any wider UI rollout. + +## Guardrail / Smoke Coverage Close-Out + +- **Close-out date**: 2026-04-30 +- **Confidence lane**: PASS via the focused customer-workspace, TenantReview detail, ReviewPack, EvidenceSnapshot, audit, capability, and download feature suites listed in [quickstart.md](quickstart.md). +- **Browser lane**: PASS via the bounded customer-review workspace smoke test. Tested path: `/admin/reviews/workspace` as a readonly-capable actor, released review row visibility, customer-safe pack/proof availability labels, workspace-to-detail handoff, and customer-safe released-review detail text. +- **Audit-gap outcome**: bounded additive action IDs were required for explicit workspace entry and proof-open events (`customer_review_workspace.opened`, `evidence_snapshot.opened`). Existing `tenant_review.opened` and `review_pack.downloaded` paths were reused with `source_surface=customer_review_workspace`. +- **Localization / copy outcome**: contained to the existing review localization namespace in English and German; no new vocabulary framework or page-local copy layer was introduced. +- **Global-search safety outcome**: no new globally searchable resource or search scope was introduced. Touched review, pack, and evidence resources remain on their existing tenant-scoped resource paths and customer-workspace query context. +- **Follow-up decision**: no `follow-up-spec` is required for the implemented scope. Broader portal, publication, remediation, baseline/control overlays, and management-packaging expansion remain outside this feature. + +## Project Structure + +### Documentation (this feature) + +```text +specs/258-customer-review-productization/ +├── checklists/ +│ └── requirements.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── customer-review-productization.openapi.yaml +└── tasks.md # Created later by /speckit.tasks, not by this plan step +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/Reviews/ +│ │ │ └── CustomerReviewWorkspace.php +│ │ └── Resources/ +│ │ ├── TenantReviewResource.php +│ │ ├── TenantReviewResource/Pages/ViewTenantReview.php +│ │ ├── ReviewPackResource.php +│ │ ├── ReviewPackResource/Pages/ViewReviewPack.php +│ │ ├── EvidenceSnapshotResource.php +│ │ └── EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php +│ ├── Http/Controllers/ReviewPackDownloadController.php +│ ├── Models/ +│ │ ├── TenantReview.php +│ │ ├── ReviewPack.php +│ │ ├── EvidenceSnapshot.php +│ │ └── FindingException.php +│ ├── Services/ +│ │ ├── Audit/WorkspaceAuditLogger.php +│ │ └── TenantReviews/TenantReviewRegisterService.php +│ ├── Support/ +│ │ ├── Audit/AuditActionId.php +│ │ ├── Auth/Capabilities.php +│ │ └── Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php +├── lang/ +│ ├── de/localization.php +│ └── en/localization.php +├── bootstrap/providers.php +├── resources/views/filament/pages/reviews/customer-review-workspace.blade.php +└── tests/ + ├── Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php + ├── Feature/ReviewPack/ReviewPackDownloadTest.php + └── Feature/Reviews/ +``` + +**Structure Decision**: Laravel monolith. The implementation stays inside the existing `apps/platform` reviews, review-pack, evidence, localization, and audit surfaces, with no new panel/provider locations and no new persistence layer. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| None expected at planning time | The intended implementation is a productization pass over existing pages, routes, copy, and audit seams | Adding a portal, new presenter layer, or persisted customer-review projection would import unnecessary structure | + +## Proportionality Review + +- **Current operator problem**: the repo already has customer-review truth, but the current workspace and drilldown still feel too operator-led and under-explain accountability, proof, and unavailable states for a customer-safe governance record. +- **Existing structure is insufficient because**: Spec 249 created the canonical entry route, but the product contract across workspace summary, released-review detail, proof pointers, pack access, and auditable access semantics is still incomplete. +- **Narrowest correct implementation**: tighten the existing workspace page, released-review detail, proof affordances, localization copy, and shared audit metadata without adding a new page shell, persistence, or customer-specific presenter family. +- **Ownership cost created**: limited copy/disclosure maintenance on existing surfaces, a small extension to focused tests, and at most bounded additive audit action IDs if current coverage is incomplete. +- **Alternative intentionally rejected**: a new portal, publication engine, remediation flow, or second customer-review explanation framework was rejected because the repo already has the required read-only truth seams. +- **Release truth**: current-release productization follow-up to Spec 249. + +## Phase 0 — Research (output: research.md) + +Research resolves the remaining implementation-shaping decisions: + +- keep the existing `CustomerReviewWorkspace` page as the canonical customer-safe landing surface +- keep `ViewTenantReview` as the secondary detail surface under the current `customer_workspace` query flag +- reuse existing localization, artifact-truth, and accepted-risk seams instead of adding a second vocabulary +- keep workspace and tenant isolation on the current capability-first RBAC paths +- reuse the existing audit pipeline and identify only the bounded missing access moments that may need additive action IDs +- keep browser coverage bounded to the existing workspace smoke path and focused feature tests + +**Output**: [research.md](research.md) + +## Phase 1 — Design (outputs: data-model.md, contracts/, quickstart.md) + +Design artifacts capture the narrow productization shape: + +- no new persistence; reused truth stays in tenant reviews, finding exceptions, review packs, evidence snapshots, memberships, and audit logs +- one derived workspace presentation contract and one derived released-review disclosure contract document the existing surfaces without becoming stored entities +- the conceptual contract documents current workspace, review detail, proof, and pack-download route expectations plus explicit access/absence/unavailable semantics +- quickstart records the intended implementation order, bounded validation commands, Filament v5 / Livewire v4 posture, provider-registration location, and no-new-assets expectation + +**Artifacts**: + +- [data-model.md](data-model.md) +- [contracts/customer-review-productization.openapi.yaml](contracts/customer-review-productization.openapi.yaml) +- [quickstart.md](quickstart.md) + +## Phase 2 — Planning (for tasks.md) + +Dependency-ordered implementation outline for the later `tasks.md` step: + +1. Tighten the existing workspace page and Blade intro so the default-visible path is calm, customer-safe, and explicit about absence/unavailable states. +2. Tighten the existing released-review detail flow under the `customer_workspace` context flag so it remains read-only and deepens understanding without exposing operator lifecycle actions. +3. Reuse existing review summary, finding outcome, accepted-risk, proof, and pack truth to improve explanation quality and customer-safe disclosure hierarchy without adding a second presenter or new persistence. +4. Align proof pointers and review-pack affordances so optional deep links are capability-gated and unavailable states are explicit. +5. Reuse the shared audit pipeline for workspace access, review access, proof access, and pack downloads, adding only bounded audit registry entries if the current actions do not cover required moments. +6. Expand the focused review feature suite and keep the single existing browser smoke as the only browser proof for this slice. + +## Planning Guardrail Notes + +- Planning guardrail result: PASS. Filament remains v5 on Livewire v4, panel providers remain in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new global-search scope is introduced, no destructive action is added, and no new asset bundle is planned. +- Shared seam result: the plan stays on existing page/resource/service/audit seams, not a new customer-review framework. +- Smoke plan: the existing [../../apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php](../../apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php) remains the single bounded browser proof. +- Agent context update: intentionally skipped during this plan pass because the feature introduces no new technology and the user requested preparation-artifact-only changes. diff --git a/specs/258-customer-review-productization/quickstart.md b/specs/258-customer-review-productization/quickstart.md new file mode 100644 index 00000000..a5fb8692 --- /dev/null +++ b/specs/258-customer-review-productization/quickstart.md @@ -0,0 +1,55 @@ +# Quickstart — Customer Review Workspace Productization v1 + +## Preconditions + +- Docker is running and the Sail stack for `apps/platform` is available. +- The feature remains inside the existing Laravel monolith and existing admin plane. +- The canonical entry surface already exists at `/admin/reviews/workspace`; this slice productizes it instead of adding a new shell. +- No new persistence, no review publishing engine, no remediation flow, no new identity plane, and no heavy new asset strategy are part of this work. + +## Intended Implementation Order + +1. Review the current workspace page, Blade intro, and feature/browser tests so the productization pass stays inside the existing reviews family. +2. Tighten the workspace page wording, disclosure order, and explicit access/absence/unavailable states using the existing localization namespace in [../../apps/platform/lang/en/localization.php](../../apps/platform/lang/en/localization.php) and [../../apps/platform/lang/de/localization.php](../../apps/platform/lang/de/localization.php). +3. Tighten the released-review detail flow in [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) under the existing `customer_workspace` context flag so it remains read-only and customer-safe. +4. Reuse the current `TenantReview.summary`, `FindingException`, `ArtifactTruthPresenter`, review-pack, and evidence seams to improve accountability/proof framing without creating a new presenter or persistence layer. +5. Align secondary proof and pack affordances so the workspace still has one dominant next action and optional proof paths show explicit unavailable or expired states when blocked. +6. Reuse the shared audit pipeline for workspace access, review access, proof access, and pack download moments, adding only bounded action IDs if the current registry does not already cover the required events. +7. Expand the focused `tests/Feature/Reviews/*` family and keep the existing browser smoke as the only browser proof for this slice. +8. Run the targeted tests and Pint after implementation. + +## Targeted Validation Commands (after implementation) + +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` +- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## Planned Smoke Checklist (after implementation) + +1. Sign in to `/admin` as a readonly-capable actor with workspace scope and open `/admin/reviews/workspace`. +2. Confirm the page stays calm and customer-safe: current governance record first, no mutation actions, and explicit absence/unavailable states where appropriate. +3. Launch the workspace from an existing released review or related context and confirm tenant prefilter and customer-safe drilldown continuity still hold. +4. Open the released review and confirm the detail stays read-only, highlights findings/accepted-risk/accountability/proof clearly, and does not expose publish/refresh/create-next/regenerate/archive controls. +5. Use the pack action for a tenant with a current pack and confirm the existing signed download path still works; for tenants without a current or still-valid pack, confirm the UI shows a truthful unavailable or expired state instead of a generation action. +6. Follow an optional proof path and confirm the route is capability-gated, auditable when required, and explicit when proof is unavailable or redacted. +7. Attempt an explicit out-of-scope tenant target and confirm the result remains not found without leaking tenant presence. + +## Notes + +- Filament v5 already runs on Livewire v4 in this repo. +- Panel providers remain registered through [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php); this slice does not add or move providers. +- No new globally searchable resource or search scope is part of this productization pass. +- No destructive action belongs on the workspace surface or the customer-workspace drilldown. If implementation accidentally exposes one, it must stay out of scope and use confirmation. +- No new registered asset bundle is expected. If future implementation unexpectedly registers a Filament asset, deployment still requires `cd apps/platform && php artisan filament:assets`. +- This remains a customer-safe consumption/productization slice only. Review creation, publication, regeneration, remediation, and broader portal behavior stay outside this spec. + +## Implementation Close-Out + +- **Completed**: 2026-04-30 +- **Targeted feature checks**: PASS +- **Browser smoke**: PASS, covering `/admin/reviews/workspace`, released-review row visibility, customer-safe pack/proof labels, workspace-to-detail handoff, and released-governance-record detail text. +- **Formatting**: PASS via Pint dirty-file run. +- **Audit result**: used bounded additive action IDs only for the confirmed gaps (`customer_review_workspace.opened`, `evidence_snapshot.opened`); reused existing tenant-review open and review-pack download audit events with `source_surface=customer_review_workspace`. +- **Global-search result**: unchanged; this implementation added no global-search surface. +- **Assets / deploy result**: unchanged; no new Filament assets were registered. diff --git a/specs/258-customer-review-productization/research.md b/specs/258-customer-review-productization/research.md new file mode 100644 index 00000000..8b0fef1a --- /dev/null +++ b/specs/258-customer-review-productization/research.md @@ -0,0 +1,156 @@ +# Research — Customer Review Workspace Productization v1 + +**Date**: 2026-04-30 +**Spec**: [spec.md](spec.md) + +This document resolves the planning decisions for the smallest safe productization follow-up to Spec 249. + +## Decision 1 — Keep the existing customer review workspace as the canonical landing surface + +**Decision**: Productize the existing [../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php](../../apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php) page instead of creating a second customer-review page, new Resource, new panel, or customer portal shell. + +**Rationale**: +- The repo already has the canonical admin-plane route, current tenant prefilter behavior, and bounded test family for this page. +- The gap is productization of the current disclosure contract, not missing routing or missing persistence. +- Reusing the existing page keeps the follow-up aligned with Spec 249 and avoids a second customer-review vocabulary. + +**Alternatives considered**: +- Add a second customer-facing page or shell. + - Rejected: duplicates the existing workspace route and widens scope into shell-level IA. +- Convert the workspace into a new Resource. + - Rejected: this is still a read-only workspace report, not a new persisted object family. + +## Decision 2 — Keep the existing released-review detail route as the only secondary context surface + +**Decision**: Continue to use [../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php](../../apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php) as the secondary context surface reached from the workspace by the existing `customer_workspace` query flag. + +**Rationale**: +- The current detail page already suppresses management actions when launched from the customer-workspace flow. +- The current route already writes customer-workspace review-open audit metadata. +- Productization should deepen understanding on the current drilldown path instead of inventing a second detail page. + +**Alternatives considered**: +- Add a customer-only review detail page. + - Rejected: would duplicate detail truth and drift from the current policy/audit path. +- Push all new explanation back onto the workspace page only. + - Rejected: would keep the first drilldown operator-heavy and incomplete. + +## Decision 3 — Reuse the existing review summary and artifact-truth seams for findings, accepted-risk, and proof framing + +**Decision**: Reuse the current `TenantReview.summary` payload, [../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php](../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php), and current review/evidence/pack relationships for customer-safe findings, accepted-risk, and proof framing. + +**Rationale**: +- The workspace page already derives `finding_outcomes` and `risk_acceptance` summary content from the current review payload. +- `ArtifactTruthPresenter` already normalizes review and pack freshness/publication semantics. +- The productization gap is wording, priority, and explicit unavailable-state behavior, not missing domain truth. + +**Alternatives considered**: +- Introduce a customer-review presenter or new derived persistence layer. + - Rejected: adds structure without new source-of-truth need. +- Inline a second page-local taxonomy for findings and proof states. + - Rejected: creates shared-language drift across workspace, review, and pack surfaces. + +## Decision 4 — Keep accepted-risk accountability rooted in existing finding-exception truth + +**Decision**: Accepted-risk accountability remains derived from existing `FindingException` and current decision truth where available; missing accountable-person or accountable-role data must surface as partial/unavailable disclosure rather than a fabricated customer-safe summary. + +**Rationale**: +- The spec explicitly forbids new persistence and new decision stores. +- Existing `FindingException` truth already includes owner, approver, current decision, validity, review due, and evidence reference relationships. +- Productization requires better accountability framing, but not a parallel accepted-risk model. + +**Alternatives considered**: +- Add a new customer-accountability projection. + - Rejected: violates the no-new-persistence goal. +- Hide accepted-risk accountability when details are incomplete. + - Rejected: weakens the governance-of-record objective and obscures truthful partiality. + +## Decision 5 — Keep workspace and tenant isolation on the current capability-first seams + +**Decision**: Reuse [../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php), [../../apps/platform/app/Services/Auth/RoleCapabilityMap.php](../../apps/platform/app/Services/Auth/RoleCapabilityMap.php), and [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php) as the isolation and entitlement seams. + +**Rationale**: +- The current workspace page already uses `canAccessWorkspace(...)`, `authorizedTenants(...)`, and the current workspace context. +- The existing test family already proves `404` semantics for out-of-scope tenant targeting on the workspace route. +- Capability-first reuse avoids new role families or customer-only policy forks. + +**Alternatives considered**: +- Add new customer-review roles or customer-specific policy branches. + - Rejected: outside scope and unnecessary for the current admin-plane audience. +- Resolve optional proof access by hiding the entire review. + - Rejected: the review should remain readable even when optional proof is unavailable. + +## Decision 6 — Treat access, absence, unavailable, expired, and redacted conditions as derived disclosure states only + +**Decision**: The follow-up should make access, absence, unavailable, expired, and redacted conditions explicit across workspace, review, evidence, and pack paths, but these remain derived view semantics rather than new persisted statuses. + +**Rationale**: +- The current workspace page already distinguishes `No published review available yet` and pack availability from persisted review lifecycle state. +- The spec explicitly rejects new state families and new persistence. +- Explicit customer-safe disclosure is the sellability gap; a new persisted taxonomy is not. + +**Alternatives considered**: +- Add a new customer-availability enum family. + - Rejected: presentation-only distinction with no new lifecycle consequence. +- Leave absence and unavailable states implicit. + - Rejected: that is the current productization gap. + +## Decision 7 — Reuse the current review-pack and evidence proof routes instead of adding new proof viewers + +**Decision**: Keep [../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php](../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php), [../../apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php](../../apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php), and [../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php](../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php) as the only proof/detail routes reused by this feature. + +**Rationale**: +- Current pack download already enforces tenant access, capability checks, readiness, expiry, and audit logging. +- Current evidence resource routing already exists and is referenced by review and pack truth presenters. +- The follow-up only needs clearer proof-pointer semantics and explicit unavailable states, not a new viewer family. + +**Alternatives considered**: +- Add a new customer-proof page or customer-only download endpoint. + - Rejected: duplicates existing review-pack and evidence seams. +- Surface raw evidence payloads directly on the workspace page. + - Rejected: violates customer-safe disclosure hierarchy. + +## Decision 8 — Reuse the existing audit pipeline and extend it only where access moments are still missing + +**Decision**: Keep all auditing on [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) and [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php). Existing review-open and pack-download actions stay authoritative, and only bounded additive action IDs should be considered if workspace access or proof access moments are not yet covered. + +**Rationale**: +- The current customer-workspace review handoff already logs `TenantReviewOpened`. +- The current signed pack download route already logs `ReviewPackDownloaded`. +- The repo does not currently show a matching evidence-proof-open audit on the customer-workspace flow, so that is the bounded gap to evaluate. + +**Alternatives considered**: +- Create a new customer-review audit table or analytics stream. + - Rejected: unnecessary persistence and duplication. +- Leave workspace/proof access unaudited. + - Rejected: the spec explicitly requires auditability for those consumption moments. + +## Decision 9 — Keep browser coverage bounded to the existing smoke harness + +**Decision**: Reuse [../../apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php](../../apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php) as the single browser smoke proof and expand only the existing `tests/Feature/Reviews/*` family for the rest. + +**Rationale**: +- The repo already has a browser harness for the review-detail-to-workspace handoff and calm disclosure checks. +- The rest of the productization contract is better proven in focused feature tests for omission rules, access states, and audit metadata. +- Adding a broader browser family would expand governance cost without clearer business proof. + +**Alternatives considered**: +- Add multiple new browser tests for each proof path. + - Rejected: too heavy for a bounded productization follow-up. +- Rely on browser testing alone. + - Rejected: feature tests remain the narrower proof for RBAC and disclosure-state coverage. + +## Decision 10 — Keep the feature asset-light and non-search-expanding + +**Decision**: Do not add a new asset bundle, new panel assets, or any new global-search scope as part of this follow-up. + +**Rationale**: +- The work is a content/disclosure/productization pass over existing Filament surfaces. +- Existing review, review-pack, and evidence resources already avoid global-search exposure in this customer-safe path. +- The user explicitly scoped out heavy asset strategy and new global-search work. + +**Alternatives considered**: +- Add new visual infrastructure or customer-safe asset loading. + - Rejected: no product need for this slice. +- Add a new search entry point for the customer workspace. + - Rejected: outside the tenant-safe reuse boundary and unnecessary for the current route. \ No newline at end of file diff --git a/specs/258-customer-review-productization/spec.md b/specs/258-customer-review-productization/spec.md new file mode 100644 index 00000000..71081c23 --- /dev/null +++ b/specs/258-customer-review-productization/spec.md @@ -0,0 +1,348 @@ +# Feature Specification: Customer Review Workspace Productization v1 + +**Feature Branch**: `258-customer-review-productization` +**Created**: 2026-04-30 +**Status**: Draft +**Input**: User description: "Customer Review Workspace Productization v1 as the smallest follow-up slice that hardens the existing customer review workspace into a calmer customer-safe review consumption surface with clearer findings, accepted-risk, evidence-summary, and auditable pack-access semantics without adding a portal, new persistence, or write paths." + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: TenantPilot already has repo-real review, evidence, review-pack, redaction, RBAC, and audit foundations plus an existing customer review workspace, but the current surface still reads more like an operator-led admin handoff than a fully customer-safe governance-of-record consumption path. +- **Today's failure**: Authorized customer reviewers, customer admins, and auditors can reach review truth, but they still have to infer meaning from operator-oriented wording, incomplete accepted-risk/accountability framing, uneven evidence proof semantics, and unclear access or absence states. That keeps the surface harder to sell and less trustworthy than the underlying repo truth. +- **User-visible improvement**: An authorized reader can open one existing workspace review surface and understand what was reviewed, what matters, what risk was accepted, what evidence exists, and what can be safely accessed or downloaded without seeing mutation paths, operator residue, or raw diagnostics. +- **Smallest enterprise-capable version**: Productize the existing customer review workspace and related released-review detail flow inside the current admin plane as a clearer read-only governance-of-record surface with calmer wording, findings and accepted-risk/accountability summaries, evidence proof pointers, explicit unavailable states, and auditable access/download semantics. +- **Explicit non-goals**: No new panel, no new provider registration path, no portal, no new identity plane, no new persistence, no review authoring or publishing engine, no remediation or mutation flow, no new review generation or regeneration flow, no AI summaries, no new global-searchable resource, no heavy new assets, and no destructive actions. +- **Permanent complexity imported**: One bounded productization pass over existing workspace and released-review surfaces, refined disclosure and access-state rules, explicit audit coverage for access/download behavior, focused feature-test expansion, and one bounded browser smoke check. No new models, enums, persisted artifacts, provider seams, or presentation frameworks are introduced. +- **Why now**: This remains the highest-priority active candidate in the roadmap overlay, and the implementation ledger still marks customer review productization as incomplete even though the underlying review foundations are already repo-real. +- **Why not local**: Isolated copy fixes or one-off detail-page tweaks would not establish a coherent customer-safe contract across workspace summary, released review detail, accepted-risk accountability, evidence proof pointers, and access/download semantics. +- **Approval class**: Core Enterprise +- **Red flags triggered**: Multi-surface disclosure contract and customer-facing wording inside an existing admin-plane surface. Defense: the slice stays read-only, derived, in-panel, and reuses current review/evidence/review-pack/RBAC/redaction/audit truth without adding new persistence or authoring behavior. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view +- **Primary Routes**: + - existing admin-plane customer review workspace at `/admin/reviews/workspace` + - existing tenant-scoped released review detail route for the selected review + - existing review-pack access/download surface reached from released review context + - existing evidence summary/proof routes reached from released review context when the actor is entitled +- **Data Ownership**: All visible truth remains derived from existing tenant-owned review, finding, accepted-risk, evidence, review-pack, and audit records in the current workspace. No new workspace-owned or tenant-owned persisted artifact, projection table, or publication store is introduced. +- **RBAC**: + - this remains an admin-plane follow-up, not a new panel or plane + - workspace membership remains the first isolation boundary + - page entry requires an established workspace scope plus at least one entitled tenant the actor may read through the existing capability registry + - proof pointers, evidence summaries, and review-pack downloads remain capability-gated through current review/evidence/pack authorization paths + - non-members or out-of-scope tenant requests resolve as deny-as-not-found + - member actors lacking an optional gated capability receive capability denial only for the gated deep link or access path + - no new customer-only role family or identity model is introduced + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: When the workspace is launched from a tenant-scoped review or related review context, it prefilters to that tenant and foregrounds the latest released review for that tenant. Without an incoming tenant context, the page shows only entitled tenants in the current workspace. +- **Explicit entitlement checks preventing cross-tenant leakage**: Aggregated lists, review detail entry, proof pointers, and pack access only resolve for tenants the actor is entitled to in the current workspace. Inaccessible tenant targets are omitted from aggregated lists and resolve as not found when directly targeted. + +## 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 +- **Interaction class(es)**: status messaging, evidence/report viewers, action links, navigation entry points, access-state messaging, and review-pack access/download affordances +- **Systems touched**: existing `CustomerReviewWorkspace`, existing released review detail surfaces, existing review-pack access/download surface, existing evidence summary/proof presentation, existing redaction messaging, existing localization review copy, and existing audit infrastructure +- **Existing pattern(s) to extend**: the current customer review workspace, existing released review detail path, existing artifact-truth presentation, existing redaction notes, and current review-pack access semantics +- **Shared contract / presenter / builder / renderer to reuse**: `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `SurfaceCompressionContext`, `ActionSurfaceDeclaration`, existing review/evidence/review-pack disclosure surfaces, and the current audit logging path +- **Why the existing shared path is sufficient or insufficient**: The underlying review, evidence, pack, and audit truth is already present and authoritative. What is insufficient is the current customer-safe product contract over that truth, not the underlying data model or access seams. +- **Allowed deviation and why**: none. This follow-up must tighten the existing shared path rather than introduce a second customer-review vocabulary or mirror presenter. +- **Consistency impact**: Review outcome wording, findings severity and status language, accepted-risk accountability phrasing, evidence proof terminology, pack availability states, redaction notes, and audit action labels must stay aligned across workspace and released-review detail flows. +- **Review focus**: Reviewers must block any new page-local taxonomy, raw-payload viewer, duplicate proof summary, or portal-only terminology that drifts from current review/evidence/review-pack truth. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: no +- **Shared OperationRun UX contract/layer reused**: `N/A` +- **Delegated start/completion UX behaviors**: `N/A` +- **Local surface-owned behavior that remains**: This slice stays read-only and does not add new run start, queue, resume, or completion behavior. Any existing deep provider or run diagnostics remain outside the default customer-safe path. +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +N/A - no shared provider/platform boundary touched. The slice consumes existing governance review truth and does not widen provider-specific semantics, identity scope, or shared platform taxonomy. + +## 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 | +|---|---|---|---|---|---|---| +| Customer Review Workspace page | yes | Native Filament page plus shared review/evidence primitives | status messaging, evidence/report viewers, access-state messaging, pack access | page, table/filter, disclosure state | no | Existing page is materially productized rather than replaced | +| Released Customer Review detail | yes | Native Filament resource/detail surface plus shared review/evidence primitives | status messaging, proof pointers, pack access, progressive disclosure | detail sections, disclosure state | no | Existing detail flow becomes the secondary customer-safe context surface | + +## 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 | +|---|---|---|---|---|---|---|---| +| Customer Review Workspace page | Primary Decision Surface | Customer reviewer, customer admin, or auditor decides whether the released review answers the current governance question or needs a follow-up conversation with the workspace operator team | released review state, calm outcome summary, key findings summary, accepted-risk/accountability summary, evidence/proof availability, and pack access state | released review detail, review-pack metadata, and proof pointers only after explicit open | Primary because it is the first truthful customer-safe route and should answer the high-level question without requiring a detail drilldown | Follows review-consumption workflow, not storage-object or operator-workbench navigation | Replaces cross-surface reconstruction with one calm starting point | +| Released Customer Review detail | Secondary Context Surface | Reader inspects the chosen released review to understand why the current governance record looks the way it does and what proof or packaged artifact is available | narrative outcome summary, findings summary, accepted-risk/accountability explanation, evidence summary, proof pointers, and pack availability | redacted proof references, release history, and gated secondary diagnostics only after explicit expansion | Not primary because it is entered from the workspace summary and should deepen understanding rather than replace the decision-first landing surface | Keeps the operator journey centered on one review case after the workspace summary selects it | Preserves a focused second step instead of exposing every proof or diagnostic on the first page | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Customer Review Workspace page | customer-read-only, customer-admin, auditor-read-only, operator-MSP | what was reviewed, current outcome, key findings, accepted risks, evidence/proof availability, pack availability, and clear access/unavailable states | release timing, review lineage, and secondary freshness detail only after opening the review | raw payloads, provider IDs, run internals, and unrestricted diagnostics stay out of the default path | `Open released review` | raw/support detail, deep diagnostics, and unavailable secondary access paths stay hidden until explicitly requested and entitled | Workspace summary states each tenant review truth once; the detail surface elaborates instead of restating the same overview blocks | +| Released Customer Review detail | customer-read-only, customer-admin, auditor-read-only, operator-MSP | calm narrative outcome, findings summary, accepted-risk/accountability context, evidence summary with proof pointers, pack access state, and explicit redaction/access explanations | release history, evidence freshness detail, and secondary metadata only in collapsible or separately gated sections | raw evidence payloads, provider-debug detail, and unrestricted audit internals remain hidden or capability-gated | `Download current review pack` | raw/support detail, broad diagnostics, and any operator-only sections remain excluded from the customer-safe default view | Workspace page provides the overview; detail adds explanation and proof pointers without duplicating full summary cards at equal priority | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Customer Review Workspace page | List / Table / Read-only workspace report | Read-only registry report | Open the released review for the selected tenant | full-row open to released review detail | required | one safe inline pack-access affordance only when already available and entitled | none | `/admin/reviews/workspace` | `/admin/t/{tenant}/reviews/{record}` | workspace context, tenant prefilter, release state, pack availability | Customer review | what was reviewed, the released outcome, key findings, accepted-risk/accountability, proof availability, and access state | none | +| Released Customer Review detail | Detail / Report / Evidence | Read-only detail report | Download the current review pack or inspect proof pointers | sectioned detail page with one dominant safe header action | forbidden | proof pointers live in secondary evidence sections and capability-gated safe surfaces after the pack action | none | `/admin/reviews/workspace` | `/admin/t/{tenant}/reviews/{record}` | workspace, tenant, review release state, evidence summary, pack availability | Customer review | why this is the current governance record and what proof or packaged artifact is available | 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Customer Review Workspace page | Customer reviewer, customer admin, or auditor with read access | Decide whether the released review is sufficient for current governance consumption or whether human follow-up is needed | Read-only workspace review overview | What was reviewed for my tenant, what matters now, what was accepted, and what can I safely open or download? | released outcome, key findings, accepted-risk/accountability summary, evidence/proof availability, pack access state, and explicit absence/unavailable messaging | release lineage, deeper evidence freshness, and secondary access metadata only after drilldown | review outcome, findings severity mix, accepted-risk lifecycle, evidence completeness, pack availability, access state | none | Open released review | none | +| Released Customer Review detail | Customer reviewer, customer admin, or auditor with read access | Understand the current governance record and consume proof or packaged artifacts safely | Read-only detail report | Why does the review say this, what evidence supports it, and what packaged artifact is available? | calm narrative summary, findings and recommendations, accepted-risk/accountability context, evidence summary and proof pointers, pack access state | release history, deeper proof metadata, and gated secondary diagnostics | review outcome, evidence freshness/completeness, accepted-risk timing, pack availability, redaction/access state | none | Download current review pack | none | + +## 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?**: no +- **Current operator problem**: The repo already holds released review truth, but the current surface still makes customer-safe governance consumption harder than it should be by leaving too much operator framing and too little explicit accountability/proof language in the default path. +- **Existing structure is insufficient because**: The current workspace and detail contract does not yet consistently separate customer-readable review summary from secondary diagnostics, nor does it make access, absence, and unavailable states explicit enough for a sellable governance-of-record surface. +- **Narrowest correct implementation**: Tighten the existing workspace and released-review detail surfaces, reusing current review/evidence/review-pack/redaction/RBAC/audit truth and adding no new persistence, panel, or workflow engine. +- **Ownership cost**: A bounded copy and disclosure pass, focused audit assertions, targeted feature-test expansion, and one bounded browser smoke. +- **Alternative intentionally rejected**: A separate portal, customer-specific persistence layer, or new review publication framework was rejected because the repo already has the necessary read-only review truth and access seams. +- **Release truth**: current-release sellability blocker, not future-release preparation + +### 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, Browser +- **Validation lane(s)**: confidence, browser +- **Why this classification and these lanes are sufficient**: Focused feature tests are the narrowest sufficient proof for entitlement boundaries, progressive disclosure, explicit absence/access states, localization-ready copy expectations, and auditable access/download semantics. One bounded browser smoke remains justified to prove the calm default-visible path and the one-dominant-action flow under real UI rendering. +- **New or expanded test families**: expand the existing `Reviews/CustomerReviewWorkspace` feature family; keep exactly one bounded browser smoke around the same surface +- **Fixture / helper cost impact**: low to moderate. Reuse existing workspace membership, tenant entitlement, released review, findings, accepted-risk/exception, evidence snapshot, review pack, redaction, and audit fixtures instead of adding new provider or queue-heavy defaults. +- **Heavy-family visibility / justification**: exactly one browser smoke stays explicit because this slice is mostly about customer-safe wording, disclosure, and safe action placement. No broader browser family is introduced. +- **Special surface test profile**: shared-detail-family +- **Standard-native relief or required special coverage**: ordinary Filament feature coverage is sufficient for routing, authorization, disclosure, and unavailable states; the existing bounded smoke is the only required browser proof. +- **Reviewer handoff**: Reviewers must confirm that the customer-safe default view never exposes operator-only or mutation actions, that unauthorized tenant targets do not leak presence, that access/download events stay auditable, that review/evidence/pack absence states are explicit, and that no new global search leakage is introduced. +- **Budget / baseline / trend impact**: low feature-local increase only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Smoke Coverage +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` + +## Scope Boundaries + +### In Scope + +- productize the existing admin-plane customer review workspace into a clearer customer-safe read-only governance-of-record surface +- tighten the released-review detail flow so it remains a customer-safe secondary context surface rather than an operator-heavy detail page +- findings summary in calm customer-safe language, including severity, status, impact, and recommendation +- accepted-risk/accountability summary using existing risk-acceptance truth, including decision reason, accountable person or role when available, timing, and expiry or re-review state +- evidence summary and proof pointers using existing evidence truth without raw payload default visibility +- explicit access, absence, unavailable, expired, and redaction states across workspace, review, evidence, and pack access +- auditable workspace access, review access, evidence-summary/proof access, and review-pack download behavior +- progressive disclosure boundaries that keep the default path calm and customer-readable +- reuse of existing localization, redaction, RBAC, audit, review-pack, and evidence truth + +### Non-Goals + +- a new panel, portal, standalone customer shell, or separate identity plane +- new persistence, new publication state families, or a customer-specific projection store +- review authoring, review publishing, review generation, pack generation/regeneration, remediation, or other write paths +- risk acceptance editing, finding triage, owner reassignment, or admin mutation flows +- AI-generated review summaries or AI-generated recommendation layers +- raw evidence payload viewers, provider-debug views, or new operator diagnostics in the customer-safe default path +- a new global-searchable review or evidence resource +- broader baseline/control overlays or cross-surface "next sensible step" orchestration beyond the existing released-review contract +- new heavy frontend assets or a standalone asset-loading strategy + +## Dependencies + +- existing `CustomerReviewWorkspace` route and navigation entry point +- existing released `TenantReview` detail surface and release-truth semantics +- existing `ReviewPack` access and download behavior +- existing `EvidenceSnapshot` summaries and proof context +- existing finding and accepted-risk/exception truth +- existing redaction behavior and safe review-pack disclosure +- existing workspace and tenant RBAC plus entitlement enforcement +- existing audit logging for access and artifact events +- existing DE/EN localization posture for review-facing copy + +## Assumptions + +- The existing Filament v5 and Livewire v4 admin-plane customer review workspace remains the canonical entry surface for v1; no new panel, portal shell, or provider registration change is required. +- Released review truth already exists and remains authoritative for what is customer-safe to consume. +- Accepted-risk/accountability summaries can be derived from existing risk-acceptance or exception truth without inventing a new customer-facing decision store. +- Review packs remain the primary packaged export/proof artifact, and this slice only clarifies access and wording around them. +- Existing panel assets are sufficient; this slice does not justify heavy new asset registration or on-demand asset infrastructure. +- Existing tenant-safe search behavior remains unchanged; this slice does not depend on introducing a new global search surface. + +## Risks + +- Some released reviews may not yet carry fully populated accountable-person or accountable-role data, which could force partial accountability summaries until the underlying product truth is present. +- If productization changes only the workspace page and not the released-review detail follow-through, the customer-safe contract could still feel inconsistent after drilldown. +- Over-eager implementation could try to smuggle in publication, regeneration, or remediation behavior because the surrounding review foundations already exist; this spec must block that scope growth. +- Access auditing for read-only views can be under-specified if the implementation focuses only on download events; the slice must treat access and download behavior as separate auditable moments. +- If access, absence, and unavailable states are not differentiated clearly, users may misread missing proof as a system error or hidden operator state rather than a truthful product condition. + +## Candidate Selection Rationale + +- **Selected candidate**: Customer Review Workspace Productization v1 +- **Source locations**: + - `docs/product/spec-candidates.md` active P0 candidate + - `docs/product/roadmap.md` priority order item 1 + - `docs/product/implementation-ledger.md` open gap `Customer review productization remains incomplete` + - `specs/249-customer-review-workspace/spec.md` as the immediate predecessor spec + - existing repo surface and tests centered on the current customer review workspace +- **Why selected**: This is the highest-priority active unspecced follow-up that converts already repo-real review foundations into a more sellable customer-safe product surface without reopening foundations or requiring new persistence. +- **Why this is the smallest viable implementation slice**: The repo already has the workspace page, review detail, evidence, review-pack, redaction, RBAC, and audit truth. The missing piece is productization of wording, accountability summary, proof framing, and access-state semantics, not a new review engine. +- **Intentional narrowing from source candidate**: This slice deliberately defers broader baseline/control context overlays and richer cross-surface next-step guidance from the roadmap candidate. Those remain follow-up work for `Compliance Evidence Mapping v1` and `Governance-as-a-Service Packaging v1` after the customer-safe review contract is stable. +- **Why close alternatives are deferred**: + - Governance Decision Surface Convergence already has `specs/257-governance-decision-convergence` and addresses a different operator convergence lane. + - Remove Findings Lifecycle Backfill Runtime Surfaces already has `specs/253-remove-findings-backfill-runtime-surfaces` and is a cleanup lane, not the current customer-safe sellability blocker. + - Remove Legacy Acknowledged Finding Status Compatibility already has `specs/254-remove-acknowledged-compat` and is a workflow semantics cleanup lane. + - Enforce Creation-Time Finding Invariants already has `specs/255-enforce-finding-creation-invariants` and is a data-integrity hardening lane. + - Cross-Tenant Compare and Promotion v1 already has `specs/043-cross-tenant-compare-and-promotion` and remains a separate refresh track rather than the next unspecced customer-review productization slice. + +## Follow-up Candidates + +- Governance-as-a-Service Packaging v1 once released reviews, proof pointers, and accepted-risk summaries are stable enough to package as a repeatable management deliverable +- Compliance Evidence Mapping v1 once the customer-safe review surface needs a stronger control/readiness and baseline/control-context overlay than this released-review productization slice intentionally provides +- Cross-Tenant Compare and Promotion v1 as the next MSP multiplier after customer-safe review consumption is calmer +- Broader risk acceptance and accountability reporting follow-through if customer-safe accountability views need portfolio or management-level rollups beyond the review surface + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Understand the latest released review at a glance (Priority: P1) + +A customer reviewer, customer admin, or auditor wants one calm workspace route that shows the latest released review for each entitled tenant so they can understand the current governance record without reconstructing it from operator-oriented screens. + +**Why this priority**: This is the core sellability gap. If the reader still has to search across internal review, evidence, and pack surfaces to understand the current state, the productization slice fails. + +**Independent Test**: Sign in as an entitled read-only actor, open the customer review workspace, and confirm that each visible tenant shows a released review summary with findings, accepted-risk/accountability, evidence/proof, and pack availability in customer-safe wording. + +**Acceptance Scenarios**: + +1. **Given** an entitled actor has access to one or more tenants with released reviews, **When** they open the customer review workspace, **Then** they see only entitled tenants and only released review summaries in the default path. +2. **Given** a tenant has released findings, accepted risks, and proof-backed evidence, **When** the actor scans the workspace row or card, **Then** they can understand what was reviewed, what matters, and what proof or pack is available without opening a second page first. +3. **Given** the actor has no entitled tenant with a released review, **When** they open the workspace, **Then** they see a truthful absence state rather than leaked draft or hidden internal review states. + +--- + +### User Story 2 - Understand why the review says what it says (Priority: P1) + +A customer reviewer, customer admin, or auditor wants the released review detail to explain findings, accepted-risk/accountability, and evidence proof in calmer customer-safe wording so they can trust the review as the governance record without seeing admin or debug residue. + +**Why this priority**: Productization is not complete if the workspace summary is calmer but the first drilldown still feels like an operator surface. + +**Independent Test**: Open a released review from the workspace and verify that the default detail view shows summary, findings, accepted-risk/accountability, and evidence proof pointers while hiding mutation actions and raw diagnostics. + +**Acceptance Scenarios**: + +1. **Given** a tenant has a released review with findings and accepted risks, **When** the actor opens the released review detail, **Then** the page explains the outcome, recommendations, accepted-risk accountability, and proof pointers in customer-safe language. +2. **Given** deeper evidence or proof metadata exists, **When** the actor stays on the default detail view, **Then** raw payloads, provider-debug context, and broad diagnostics remain hidden until explicitly requested and entitled. +3. **Given** the actor lacks an optional capability for a secondary proof or pack action, **When** they view the released review detail, **Then** the review still remains readable while the gated access path is explicitly unavailable. + +--- + +### User Story 3 - Safely consume packaged proof and understand unavailable states (Priority: P2) + +A customer reviewer, customer admin, or auditor wants to access the current review pack or understand why it is unavailable so they can consume the released artifact without operator assistance or confusion. + +**Why this priority**: The review workspace is more trustworthy when access and absence states are explicit instead of silent or operator-only. + +**Independent Test**: Open the workspace and released review detail for tenants with and without available packs or proof access, then verify explicit access, unavailable, expired, or redacted states plus auditable access/download behavior. + +**Acceptance Scenarios**: + +1. **Given** a tenant has a current review pack and the actor is entitled to access it, **When** they choose the pack action, **Then** they can access or download the existing artifact without triggering any generation or mutation flow. +2. **Given** a tenant lacks a current review pack or proof access, **When** the actor views the workspace or released review detail, **Then** the surface shows a truthful unavailable state instead of implying a hidden operator path. +3. **Given** the actor tries to target a tenant outside their scope, **When** they open the workspace with that tenant context or a direct review link, **Then** the system resolves as not found and reveals no review or pack presence. + +### Edge Cases + +- What happens when a tenant has findings and accepted risks but no released review yet? The customer-safe workspace shows an explicit `No released review available yet` style state rather than leaking internal review lifecycle. +- What happens when a released review exists but accountability data is only partially populated? The summary shows only confirmed accountability truth and does not invent a placeholder owner or decision role. +- What happens when a review pack exists but access is unavailable because of entitlement or redaction posture? The UI shows a clear access or unavailable state and does not offer a generation or recovery action. +- What happens when a user enters through a saved filter or tenant-prefilter for an inaccessible tenant? The filter resolves safely without exposing the tenant or its review artifacts. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature introduces no Microsoft Graph calls, no write/change behavior, no new queue or scheduled behavior, and no new persistence. It changes customer-safe disclosure, authorization boundaries on read-only surfaces, and audit expectations for access/download behavior. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This follow-up must stay derived. It must not introduce new persistence, new presentation frameworks, new customer state families, or speculative abstractions. + +**Constitution alignment (XCUT-001):** The feature must extend existing review, evidence, review-pack, localization, redaction, and audit paths rather than invent a page-local customer-review semantic layer. + +**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** The default path must separate customer-readable decision content from deeper diagnostics and keep raw/support detail hidden by default. + +**Constitution alignment (TEST-GOV-001):** The implementation must stay with focused feature coverage plus one bounded browser smoke and avoid creating a broader heavy family. + +**Constitution alignment (RBAC-UX):** Workspace and tenant membership remain deny-as-not-found boundaries. Optional secondary disclosure remains capability-gated inside an established scope. No new role strings or raw capability strings are introduced by this spec. + +**Constitution alignment (UI-FIL-001 / UI-NAMING-001 / DECIDE-001 / ACTSURF-001):** The slice must remain a native Filament read-only reporting flow with one dominant safe inspect action and no destructive or mutation actions. + +### Functional Requirements + +- **FR-001**: The system MUST keep this slice inside the existing admin-plane customer review workspace and released-review detail flow rather than creating a new panel, portal, or identity surface. +- **FR-002**: The system MUST derive every visible summary, access state, and proof affordance from existing review, evidence, review-pack, accepted-risk, redaction, entitlement, and audit truth without creating new persisted customer-review artifacts. +- **FR-003**: The default workspace path MUST show only entitled tenants and only released or otherwise customer-safe reviews as the governance-of-record path. +- **FR-004**: The default-visible workspace summary MUST answer, in calm customer-safe language, what was reviewed, the current outcome, key findings, accepted risks, available evidence or proof, and pack availability. +- **FR-005**: Findings shown through this slice MUST present severity, status, impact, and recommendation in customer-safe wording and MUST not depend on operator-only vocabulary to be understood. +- **FR-006**: Accepted-risk content shown through this slice MUST summarize decision reason, accountable person or role when product truth exists, decision timing, current expiry or re-review state, and linked proof context without exposing internal workflow residue. +- **FR-007**: Evidence shown through this slice MUST present a narrative summary and proof pointers and MUST not expose raw payloads, provider-debug data, or unrestricted diagnostics by default. +- **FR-008**: The workspace and released-review detail surfaces MUST make access, absence, expired, unavailable, and redaction states explicit and understandable without implying a hidden write path or missing internal admin permission. +- **FR-009**: The feature MUST use progressive disclosure so that default-visible content remains customer-readable while deeper review detail, evidence context, and any gated secondary diagnostics appear only after explicit user intent and capability checks. +- **FR-010**: The primary workspace action MUST be opening the released review, and any secondary safe proof or pack action MUST never compete with write, remediation, generation, or admin actions. +- **FR-011**: Review-pack access and downloads MUST reuse existing entitlement, redaction, and access rules and MUST never trigger generation, regeneration, publication, or any other mutation from this slice. +- **FR-012**: Every explicit workspace access, released-review access, evidence summary or proof access, and review-pack download exposed by this slice MUST remain auditable through the current audit infrastructure. +- **FR-013**: Non-members or out-of-scope workspace or tenant requests MUST resolve as deny-as-not-found, while actors inside an established scope MUST receive explicit capability denial only for gated secondary actions or deep links they are not allowed to use. +- **FR-014**: When launched from tenant-scoped context, the workspace MUST preserve a safe tenant prefilter and context return path without broadening discovery beyond entitled tenants. +- **FR-015**: The slice MUST NOT introduce a new global-searchable resource or broaden existing search discovery in a way that reveals review or evidence artifacts across tenant boundaries. +- **FR-016**: Any new or revised status, severity, availability, or redaction labels in this slice MUST stay aligned with existing centralized semantics rather than page-local mappings. +- **FR-017**: Customer-facing labels and guidance introduced by this slice MUST remain localization-ready for the existing DE/EN product language posture. +- **FR-018**: The slice MUST expose no destructive, remediation, authoring, publishing, generation, or admin-only actions. + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Customer Review Workspace | existing `App\Filament\Pages\Reviews\CustomerReviewWorkspace` | `Clear filters` only | `recordUrl()` or full-row open to the released review | `Open released review`, `Download current review pack` when already available and entitled | none | `Clear filters` only when filters are active; otherwise explanatory no-data text with no create CTA | `N/A` | `N/A` | yes | Action Surface Contract satisfied: exactly one primary inspect model, no redundant view action, no empty action groups, no destructive actions | +| Released Customer Review detail | existing tenant-scoped released review detail surface | none by default | `N/A` | `N/A` | none | `N/A` | `Download current review pack` only; evidence summary remains in secondary in-body proof sections when entitled | `N/A` | yes | Customer-safe launch mode hides publish, refresh, generate, archive, remediation, and other operator-only actions while keeping one dominant safe header action | + +Action Surface Contract is satisfied for this slice. Each affected surface keeps one primary inspect/open model, no empty `ActionGroup` or `BulkActionGroup` placeholder, and no destructive-action placement rules are needed because destructive actions are out of scope. `UI-FIL-001` and `UX-001` are satisfied by staying inside native Filament read-only surfaces, using explicit empty states, and keeping status emphasis aligned to shared review semantics rather than page-local visual language. + +### Key Entities *(include if feature involves data)* + +- **Customer Review Workspace Summary**: A derived workspace-scoped summary for one entitled tenant that combines the current released review, findings overview, accepted-risk/accountability summary, evidence proof availability, and pack access state without becoming a persisted entity. +- **TenantReview**: The existing released review artifact that anchors what was reviewed, the current governance outcome, and the released detail path. +- **Finding**: The existing issue-level governance truth that feeds the customer-safe findings summary and recommendation framing. +- **Accepted Risk Decision**: The existing accepted-risk or exception truth that explains why a risk was accepted, who is accountable when product truth exists, and when the decision should be revisited. +- **EvidenceSnapshot**: The existing supporting proof artifact that informs evidence summaries and proof pointers. +- **ReviewPack**: The existing packaged review artifact that remains the primary downloadable proof bundle. +- **AuditLog**: The existing audit trail used to record explicit access and download behavior without introducing a new audit store. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: An entitled read-only actor can answer what was reviewed, what matters, what was accepted, what evidence exists, and what artifact is available from the customer review workspace in two interactions or fewer. +- **SC-002**: In 100% of validated customer-safe scenarios, the default-visible workspace and released-review detail path shows no mutation, remediation, publication, or operator-debug actions. +- **SC-003**: In 100% of validated unauthorized workspace or tenant access scenarios, the feature reveals no cross-tenant review, evidence, or pack presence. +- **SC-004**: For tenants with a released review and available pack, entitled users can open the released review or access the pack on their first attempt without operator assistance. +- **SC-005**: For tenants without released review truth, proof access, or a current pack, the surface explains the absence or unavailability explicitly rather than showing a blank state or generic error. diff --git a/specs/258-customer-review-productization/tasks.md b/specs/258-customer-review-productization/tasks.md new file mode 100644 index 00000000..28367ba3 --- /dev/null +++ b/specs/258-customer-review-productization/tasks.md @@ -0,0 +1,205 @@ +--- + +description: "Task list for Customer Review Workspace Productization v1" + +--- + +# Tasks: Customer Review Workspace Productization v1 + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/contracts/customer-review-productization.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/quickstart.md` + +**Tests**: Required (Pest) for runtime behavior changes. Keep proof in the narrow `confidence` lane plus one bounded `browser` smoke only because this slice changes customer-safe wording, disclosure order, and safe action hierarchy on existing workspace and detail surfaces. +**Operations**: No new `OperationRun`, queue, remote call, publication flow, remediation flow, or background processing is introduced. Auditability stays on the current shared audit pipeline only. +**RBAC**: Workspace membership remains the first boundary. Non-members or out-of-scope tenant targets remain `404`; in-scope actors missing an optional capability get explicit unavailability or `403` only on the gated secondary path. Reuse `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Auth/RoleCapabilityMap.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Auth/Capabilities.php`; do not add raw capability strings or role-string checks. +**Shared Pattern Reuse**: Reuse `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Ui/GovernanceArtifactTruth/SurfaceCompressionContext.php`, existing review-pack and evidence resources, and the shared audit logger rather than creating a new customer shell, presenter family, or persistence layer. +**Organization**: Tasks are grouped by user story so workspace productization, released-review detail hardening, and packaged-proof access remain independently testable after shared seams are settled. + +## Test Governance Notes + +- Lane assignment: `confidence` plus one explicit `browser` smoke remain the narrowest sufficient proof for capability-first RBAC, workspace and tenant isolation, calmer disclosure hierarchy, explicit unavailable states, and auditable pack or proof consumption. +- Keep new coverage inside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspace*.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`; do not widen this slice into a new browser family or a broader cross-tenant workboard suite. +- Reuse existing workspace membership, entitled-tenant, released review, finding exception, evidence snapshot, review pack, localization, and audit fixtures; any helper added during implementation must stay explicit and cheap by default. +- If implementation finds that workspace-open or proof-open auditing is already fully covered, close the corresponding audit tasks as reuse-only and record the outcome as `document-in-feature` instead of adding new action IDs. + +--- + +## Phase 1: Setup (Shared Context) + +**Purpose**: Lock the bounded productization delta, current proof lanes, and exact repo anchors before runtime edits begin. + +- [x] T001 Review the bounded slice, non-goals, guardrail outcomes, and required customer-safe behaviors in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/checklists/requirements.md` +- [x] T002 [P] Review route reuse, derived disclosure states, audit expectations, and no-new-persistence constraints in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/data-model.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/contracts/customer-review-productization.openapi.yaml` +- [x] T003 [P] Confirm the focused Sail/Pest commands, the single bounded browser-smoke requirement, and the existing review test family in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/quickstart.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Settle the shared RBAC, isolation, audit, presenter, and localization seams that every user story depends on. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [x] T004 [P] Add shared authorization coverage for workspace membership, entitled-tenant omission, deny-as-not-found tenant targeting, and in-scope optional-capability denial in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php` +- [x] T005 Reuse or minimally tighten capability-first workspace and tenant isolation in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Auth/Capabilities.php`, and existing capability maps so later summary, proof, and pack work stays on one existing seam +- [x] T006 [P] Verify and extend the shared audit pipeline only where required in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Audit/AuditActionId.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Controllers/ReviewPackDownloadController.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php` for workspace entry, review open, proof open, and pack download moments +- [x] T007 [P] Confirm the shared customer-safe truth presentation seams to reuse in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Ui/GovernanceArtifactTruth/SurfaceCompressionContext.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` before story-specific disclosure changes begin +- [x] T008 [P] Inventory the bounded copy, localization, and tenant-safe global-search seams for calmer wording, customer-safe disclosure hierarchy, explicit access-state labels, and unchanged review/evidence discoverability in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` + +**Checkpoint**: Shared RBAC, isolation, audit, presenter, and localization seams are fixed before workspace or detail productization begins. + +--- + +## Phase 3: User Story 1 - Understand The Latest Released Review At A Glance (Priority: P1) 🎯 MVP + +**Goal**: Let an entitled customer reviewer, customer admin, or auditor open one existing workspace route and immediately understand the current released governance record per entitled tenant. + +**Independent Test**: Open `/admin/reviews/workspace` as an entitled actor and confirm each visible tenant shows only released review truth, customer-safe summary wording, one dominant `Open released review` action, and explicit absence or unavailable states without leaking internal review lifecycle. + +### Tests for User Story 1 + +- [x] T009 [P] [US1] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php` for latest released review-only rows, calmer at-a-glance summaries, accepted-risk accountability visibility, evidence or pack availability summaries, and truthful no-released-review states +- [x] T010 [P] [US1] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php` for launches into `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` from existing review, evidence, and review-pack detail paths with safe tenant prefilter and no broadened tenant discovery + +### Implementation for User Story 1 + +- [x] T011 [US1] Compose one workspace entry per entitled tenant from released review truth only in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and the existing tenant-review register query seam, reusing existing `TenantReview`, current pack, and evidence relationships without draft or internal fallback +- [x] T012 [US1] Reuse or minimally tighten launch and prefilter handoffs in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` so review, evidence, and review-pack detail paths enter the workspace with preserved tenant context and no new shell +- [x] T013 [US1] Rework the default-visible workspace disclosure hierarchy in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` so outcome, key findings, accepted-risk accountability, evidence availability, and review-pack state appear as decision-first content with exactly one dominant `Open released review` action +- [x] T014 [US1] Implement explicit workspace absence, unavailable, partial, expired, and redaction-safe messaging in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` without introducing a new persisted state family +- [x] T015 [US1] Update calmer workspace wording and DE or EN localization keys in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` so operator-led language is removed from the customer-safe entry surface + +**Checkpoint**: User Story 1 is independently functional when the workspace truthfully shows only released review summaries for entitled tenants with clear customer-safe wording and explicit access-state handling. + +--- + +## Phase 4: User Story 2 - Understand Why The Review Says What It Says (Priority: P1) + +**Goal**: Let the same actor open the released review detail from the workspace and understand findings, accepted-risk accountability, and proof context without seeing operator or mutation residue. + +**Independent Test**: Open a released review from the workspace and confirm the detail stays read-only, keeps customer-safe disclosure first, preserves tenant and workspace context, and makes optional proof or pack unavailability explicit instead of hiding or leaking content. + +### Tests for User Story 2 + +- [x] T016 [P] [US2] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` for workspace-to-review handoff, preserved tenant context, safe return semantics, and deny-as-not-found behavior when `customer_workspace=1` targets an out-of-scope tenant or review +- [x] T017 [P] [US2] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` for customer-workspace read-only mode, one dominant safe action, hidden publish or remediation controls, explicit unavailable states for optional secondary actions, and preserved tenant-safe global-search posture across the touched review/evidence/pack resources +- [x] T018 [P] [US2] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php` for calmer findings language, accepted-risk accountability framing, evidence summary ordering, and hidden raw or support detail by default when the detail is launched from the workspace +- [x] T019 [P] [US2] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` for the calm workspace-to-detail handoff, one dominant primary action, and truthful optional-action unavailable states while keeping browser proof bounded to this single smoke slice + +### Implementation for User Story 2 + +- [x] T020 [US2] Tighten the existing customer-workspace detail mode in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php` so the released review remains read-only, capability-aware, and context-preserving without creating a second detail surface +- [x] T021 [US2] Reuse `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Ui/GovernanceArtifactTruth/SurfaceCompressionContext.php`, and the current review and finding-exception relationships to present calmer findings, accepted-risk accountability, and evidence-summary disclosure on the existing released-review detail surface +- [x] T022 [US2] Reuse the related detail surfaces in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php` so secondary pack and proof paths preserve customer-safe wording, source context, and explicit unavailable messaging after drilldown +- [x] T023 [US2] Update customer-safe detail, pack, and proof copy in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` so calmer wording and the customer-safe disclosure hierarchy stay aligned across the flow + +**Checkpoint**: User Story 2 is independently functional when the released review detail deepens understanding without exposing operator controls, duplicate summaries, or raw support detail by default. + +--- + +## Phase 5: User Story 3 - Safely Consume Packaged Proof And Understand Unavailable States (Priority: P2) + +**Goal**: Let the actor access the current review pack or proof route when entitled, or understand exactly why it is unavailable, expired, absent, or redacted. + +**Independent Test**: From the workspace and released-review detail, verify that current review-pack download and proof routes stay capability-gated, explicit when unavailable, auditable, and never trigger generation, regeneration, publication, or remediation behavior. + +### Tests for User Story 3 + +- [x] T024 [P] [US3] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php` for available, unavailable, expired, absent, and redacted pack or proof states plus explicit customer-safe messaging on the workspace and released-review detail surfaces +- [x] T025 [P] [US3] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php` for signed current-pack download reuse, `source_surface=customer_review_workspace` audit metadata, and the absence of generate, regenerate, or publication behavior on this path +- [x] T026 [P] [US3] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php` for proof-route capability gating, explicit unavailable or redacted handling, and shared audit logging when proof is opened from the customer review flow + +### Implementation for User Story 3 + +- [x] T027 [US3] Reuse `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Controllers/ReviewPackDownloadController.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php` so current-pack access stays capability-first, signed, auditable, and explicit when absent, unavailable, expired, or redacted +- [x] T028 [US3] Reuse `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php` so proof pointers remain customer-safe, capability-gated, and explicit when proof is absent, unavailable, or redacted instead of silently omitted +- [x] T029 [US3] Finalize shared audit wiring in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Audit/AuditActionId.php` for workspace entry, review open, proof open, and pack download only where T006 confirmed a real gap, preserving stable tenant, workspace, and `source_surface` metadata on the existing pipeline +- [x] T030 [US3] Align pack and proof access wording in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` so access, absence, unavailable, expired, and redacted states stay distinct and customer-safe + +**Checkpoint**: User Story 3 is independently functional when current pack and proof access remain bounded, auditable, and explicit under all supported access states. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Run the narrow validation set, keep formatting clean, and record bounded reviewer outcomes without widening scope. + +- [x] T031 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php` +- [x] T032 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php` +- [x] T033 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` +- [x] T034 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- [x] T035 Record the final `Guardrail / Smoke Coverage` close-out, lane results, audit-gap outcome (`reuse-only` vs bounded additive action IDs), localization or copy scope outcome, global-search safety outcome, and any `document-in-feature` or `follow-up-spec` decision in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/quickstart.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/258-customer-review-productization/checklists/requirements.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: no dependencies; start immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories until RBAC, isolation, audit, presenter, and localization seams are settled. +- **Phase 3 (US1)**: depends on Phase 2 and delivers the MVP workspace productization slice. +- **Phase 4 (US2)**: depends on Phase 2 and should follow US1 because it deepens the same review-consumption flow on shared surfaces. +- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 or US2 because pack and proof actions build on the same workspace and detail context. +- **Phase 6 (Polish)**: depends on all implemented stories. + +### User Story Dependencies + +- **US1 (P1)**: first independently shippable increment once Phase 2 is complete. +- **US2 (P1)**: independently testable after Phase 2, but should merge after US1 because the same workspace and detail surfaces are shared hotspots. +- **US3 (P2)**: independently testable after Phase 2, but should merge after US1 because explicit access-state handling depends on the final workspace and detail wording contract. + +### Within Each User Story + +- Write the listed Pest coverage first and make it fail for the intended gap before runtime implementation. +- Reuse shared RBAC, audit, presenter, and localization seams before introducing any local helper or new copy mapping. +- Re-run the narrowest relevant proof command after each story checkpoint before moving to the next story. + +--- + +## Parallel Execution Examples + +### Phase 1 + +- T002 and T003 can run in parallel after T001 confirms the bounded slice. + +### Phase 2 + +- T004, T006, T007, and T008 can run in parallel while T005 settles the shared capability-first control path. + +### User Story 1 + +- T009 and T010 can run in parallel before runtime edits begin. +- After T011 settles row composition, T012 can proceed before T013 through T015 finalize disclosure, states, and copy. + +### User Story 2 + +- T016, T017, T018, and T019 can run in parallel because they cover different proof surfaces in the same flow. +- After the tests exist, T020 through T023 should land in order because they touch the same released-review detail family. + +### User Story 3 + +- T024, T025, and T026 can run in parallel. +- After the tests exist, T027 and T028 can proceed in parallel before T029 and T030 finalize audit and wording alignment. + +--- + +## Implementation Strategy + +### Suggested MVP Scope + +- MVP = **Phase 2 + User Story 1** only. That delivers the calmer customer-safe workspace entry surface, capability-first tenant isolation, explicit access-state handling, and safe launch continuity without yet deepening the released-review detail surface. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 and validate the workspace productization contract. +3. Deliver US2 and validate the released-review detail follow-through. +4. Deliver US3 and validate packaged-proof access, unavailable states, and audit reuse. +5. Finish with Phase 6 validation, formatting, and reviewer close-out notes. + +### Team Strategy + +1. Settle the shared capability, audit, presenter, and localization seams first. +2. Parallelize test authoring inside each story before converging on the shared workspace and detail files. +3. Serialize merges around `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` because they are the primary conflict hotspots for this slice. -- 2.45.2 From 2190322711ca91d00277668f392c67ebc5e7530f Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 18 May 2026 16:27:24 +0200 Subject: [PATCH 36/36] feat: rebuild Tenantial homepage visuals --- Agents.md | 1 + apps/website/astro.config.mjs | 2 +- apps/website/public/favicon.svg | 14 +- .../public/images/hero-product-visual.svg | 70 -- .../tenantial-logo-transparent-clean.png | Bin 0 -> 65826 bytes .../public/images/tenantial-wave-mesh.svg | 46 + .../components/content/DashboardPreview.astro | 1092 +++++++++++++++++ .../src/components/content/Headline.astro | 2 +- .../components/content/HeroDashboard.astro | 467 ------- .../components/content/TenantialLogo.astro | 35 + .../src/components/layout/Footer.astro | 10 +- .../src/components/layout/Navbar.astro | 64 +- .../src/components/layout/PageShell.astro | 13 +- .../src/components/primitives/Button.astro | 6 +- .../components/sections/FeaturePillars.astro | 70 ++ .../src/components/sections/PageHero.astro | 85 +- .../src/components/sections/TrustBar.astro | 30 + apps/website/src/content/pages/changelog.ts | 4 +- apps/website/src/content/pages/contact.ts | 10 +- apps/website/src/content/pages/home.ts | 189 +-- apps/website/src/content/pages/imprint.ts | 8 +- .../website/src/content/pages/integrations.ts | 6 +- apps/website/src/content/pages/legal.ts | 4 +- apps/website/src/content/pages/privacy.ts | 8 +- apps/website/src/content/pages/product.ts | 6 +- .../src/content/pages/security-trust.ts | 4 +- apps/website/src/content/pages/solutions.ts | 6 +- apps/website/src/content/pages/terms.ts | 8 +- apps/website/src/content/pages/trust.ts | 4 +- apps/website/src/lib/site.ts | 87 +- apps/website/src/pages/index.astro | 75 +- apps/website/src/pages/product.astro | 2 +- apps/website/src/styles/global.css | 213 ++-- apps/website/src/styles/tokens.css | 179 +-- .../tests/smoke/changelog-core-ia.spec.ts | 17 +- .../website/tests/smoke/contact-legal.spec.ts | 8 +- apps/website/tests/smoke/home-product.spec.ts | 182 +-- apps/website/tests/smoke/smoke-helpers.ts | 73 +- .../visual-foundation-guardrails.spec.ts | 8 +- .../checklists/requirements.md | 36 + .../contracts/public-homepage.yaml | 110 ++ .../data-model.md | 216 ++++ .../plan.md | 201 +++ .../quickstart.md | 73 ++ .../research.md | 94 ++ .../spec.md | 260 ++++ .../tasks.md | 257 ++++ 47 files changed, 3182 insertions(+), 1173 deletions(-) delete mode 100644 apps/website/public/images/hero-product-visual.svg create mode 100644 apps/website/public/images/tenantial-logo-transparent-clean.png create mode 100644 apps/website/public/images/tenantial-wave-mesh.svg create mode 100644 apps/website/src/components/content/DashboardPreview.astro delete mode 100644 apps/website/src/components/content/HeroDashboard.astro create mode 100644 apps/website/src/components/content/TenantialLogo.astro create mode 100644 apps/website/src/components/sections/FeaturePillars.astro create mode 100644 apps/website/src/components/sections/TrustBar.astro create mode 100644 specs/400-tenantial-homepage-visual-rebuild/checklists/requirements.md create mode 100644 specs/400-tenantial-homepage-visual-rebuild/contracts/public-homepage.yaml create mode 100644 specs/400-tenantial-homepage-visual-rebuild/data-model.md create mode 100644 specs/400-tenantial-homepage-visual-rebuild/plan.md create mode 100644 specs/400-tenantial-homepage-visual-rebuild/quickstart.md create mode 100644 specs/400-tenantial-homepage-visual-rebuild/research.md create mode 100644 specs/400-tenantial-homepage-visual-rebuild/spec.md create mode 100644 specs/400-tenantial-homepage-visual-rebuild/tasks.md diff --git a/Agents.md b/Agents.md index e092c426..50b8b846 100644 --- a/Agents.md +++ b/Agents.md @@ -942,6 +942,7 @@ ## Active Technologies - PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4 - PostgreSQL (Sail) - Tailwind CSS v4 +- TypeScript 5.9, Astro 6 static components, HTML, CSS + Astro 6.0.0, Tailwind CSS 4.2.2 through CSS-first `@theme` and `@tailwindcss/vite`, `astro-icon`, `@iconify-json/lucide`, Playwright 1.59.1 (400-tenantial-homepage-visual-rebuild) ## Recent Changes - 066-rbac-ui-enforcement-helper-v2-session-1769732329: Planned UiEnforcement v2 (spec + plan + design artifacts) diff --git a/apps/website/astro.config.mjs b/apps/website/astro.config.mjs index 3eae393b..1a650fb6 100644 --- a/apps/website/astro.config.mjs +++ b/apps/website/astro.config.mjs @@ -4,7 +4,7 @@ import tailwindcss from '@tailwindcss/vite'; import icon from 'astro-icon'; import { defineConfig } from 'astro/config'; -const publicSiteUrl = process.env.PUBLIC_SITE_URL ?? 'https://tenantatlas.example'; +const publicSiteUrl = process.env.PUBLIC_SITE_URL ?? 'https://tenantial.example'; export default defineConfig({ integrations: [icon()], diff --git a/apps/website/public/favicon.svg b/apps/website/public/favicon.svg index 8895399e..2a4f6385 100644 --- a/apps/website/public/favicon.svg +++ b/apps/website/public/favicon.svg @@ -1,11 +1,9 @@ - - + + + - + diff --git a/apps/website/public/images/hero-product-visual.svg b/apps/website/public/images/hero-product-visual.svg deleted file mode 100644 index 6ac04ffa..00000000 --- a/apps/website/public/images/hero-product-visual.svg +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - TenantAtlas - - - - WORKSPACE - - Change history - Restore preview - Review queue - Evidence - Assignments - - Current tenant - Northwind Services - Inventory linked to reviewable history - - Recent tenant changes - - Policies - - Drift - - Assignments - - CHANGE RECORD - - Windows Compliance Baseline - Version diff prepared for review - - Ready - - Conditional Access MFA - Assignment drift surfaced before rollout - - Needs review - - BitLocker policy - Restore candidate linked to prior snapshot - - Snapshot linked - - RESTORE PREVIEW - Scope validated - Assignments included - Confirmation required - - - - - REVIEW QUEUE - Conditional Access drift - Restore plan awaiting approval - Evidence attached to change record - - Change history, restore preview, and review queue stay connected on one screen. - diff --git a/apps/website/public/images/tenantial-logo-transparent-clean.png b/apps/website/public/images/tenantial-logo-transparent-clean.png new file mode 100644 index 0000000000000000000000000000000000000000..e24524c6c44df41308e360518d57295801d1ee99 GIT binary patch literal 65826 zcmeEui9b~B`~R_*L{o~SXvQ`aqbyDKj9p}ltP_t2*^8{xrm;7&WywxJI0IQy;rEn zzc91jdO+^(WIFYh_4F;R zt<67IB?prI@A{tx{-=TeY2bev_@4&;r-A=z;QuoXl-tU~v6&X{ze*$f`4yA{m{*!Z z8d@rY_Nal8R|}SNOr}}a53eeVkzsm@kZ~EOE1Bw;+}d0(6FYlw_uaVfajdnL76ME* zp9tbdVEOVUb6nn}yYHZ3p-{_8Z?~A72pOA)H3xqfR6k6y`vyi{>UjOew)*_9tN&ou z&D3Jqh^+F@g%YL56eP!ouMOaxQ`nYy+13913j722J(jgLJ?*FiolF%KTg8&b-nF}2 zsN>gh@#be`t2hzy@Pmf_L0r2ZFYi!4G{o&MJK&FQF@yIXyJ;(?7{B`lNKZ~Gc&o`G z_s(MN!V2D8@j>pDf4zcA)e>F8?K;=Jw|ImEfpJMQM;-1WUkwiDMqv4#k3Z_csW|Iv% z=)c^unVgj|%n9%JkH<<{6j>K#@)ApvKNoIO?a)bwUwf%w)zY57zegF+s+^SLry=|n zoPsj*Kcx)3&(%crACe`qQd<@Nht|Z^4^gbf<&Ecc|1kdZe&Rny=_u4<`TmPF<5;N| zWCW|DL0Pry+P+;?@dyT#R)4Qmj1>K-?|;5j0nTXi)0kK3UZ%Zip}PJB*F2v6xLh8e z4g`9R%4@w^^eFmdk^NAA3a|d#nc{@q%6VW0b|>84Ud&2?%RND`H^)?6N_2BvO^ys3 z_aAO{DH(F3`zruMmJGzbye+OyB#irU=_Lnoi@g;IGAX*}kh zTj-cTO%Lwc+LzY(?uw^&Es&iCL>()mPSK=e=H z!(nyuD6QR2BXsZnXt>59({a^vV-KQL)?fsNidBxM@~HC|bjB@9NIdjKit;h6M#A%|_3 zl-S8+478vT;Fv;>%jNPh)_#`kaayK;k#J1gl|Kglc=|P?C$c3@x0z-SYU+si`|<-D zSAMH?OnPg(Xr+C)oad;FokLYyWE~fSM&f(?@)-Vw{0;kOd$8D&=oe1FFAl*}6-c9K zfBfuFO``Wj&lv~26@fRh^=VhsDep!{T&@Oi{j7+AhAW32c?;}S#WdF2{zBdji4o6G{)SUn5q>N(a*q!{! z)4}#B<9D`CK>d6B0TG#rd-N+IFGbJz#9oRL6*W9O?89T~q6GG1U&sj;hQos|9p~7S zS!@L<)Ud4fi2&y-{Dv>fZYm zxZ!|FsL^NUclZ`G4%Ha>?HjtHlR`gsC9}ij>F$VQbfS)jP~`N|*N+Il4S8#{8@BvJ zfJ|MNeByFe?1QI+ce9`Y;_mZs##{>;g(X)lOB85#MX}#`;$B$aUtw(G$UnTKv zc0^^6#WGSq2uW~D+3o#|W10GnHp0!d`Y7Iia!a-esui3%WUj|@ClBV6?Fts}q|-jevl zp_u+->*k8&P|h8hW*5tV^?S_Mko2;b3JrFr8$aU1I%My!?0OmGMRY&?$Hsf;S|tb& z$nep>eyB|Rp%nk_J>t}2f!^X6cbli&9H0v}9_h8)Feo3esc?CpxZUseI|l^f@Y(yC z0ZA!(oHww3rfNUY#UZ^0=xDK zeQO6kg%a>FZk#mz>b&Yuj5#VGxSO!oTAI+J*DA{T{;=a{$)TrCxu-!IiK2 zd(FRA#@+(Lbe)rVIkNa_LJXF?$3o?Wni#13>I*?#y5Z62Pqlk2m1E5TWV-Tyto!W) zQ5~rNV_h%ex~y$XnN$u7DmRAqcwZL^$UB<;OA~``l6$tAg9h@BM#gtJxL#X)zE5M1 zy0IEs7)Vm$|5(=%$eWWIQ9rzB7*I)=C%L&>g_52?-kh7iHLQGacWmNH)9?_k{)*+B zQ=gheo`vjrY3#Hz@EYm=W8HErSoZ;8;P`33nEylQSb%_(r?uy)l2C^K-Gnqz`{O0I zhMaGh-W)qF<@fmi(v2WJ=3eP}F-JH0_s_fQUM*q5x2FVu_Nk``_$ptkbhT_|vkPjZ zSB_YA{rN5Uhu>q}%-n2sZ>PbUX*_*5e6AJEt(slxJZr;8WU3Fnv=3J}r!zQQqqa)Y z|5z5wdhyHeeYXOACcNlIn>8NzyW$MBcE}|}ucQCgmn8hMy3c}R!>nU|w>W#j_kPnA z{uI{Q(Art$eAg)ka{fZ1?qMaI_EoC#WQ8>A>h zVm-s7Z7i;BTpDB|o?VAj--13rwlHEtSUzKSJE#Er#A*3``rnfG-9;v!G9!$Ad_l(1?qPhPk+ zff8j60`J#OaB8hqLitPf%mGofG@k>Lxu_xOtX}g|C*O%x2@VBM8C|nxaT>H%mLV_4 zna|Uu-gv$8)HG}TGS}n3_-|FP9S+H!j4j61diH~j?Y;$kM`bLVn@Mrs)wA1eZSuR3LmHPVVvCSLVevL8@ZLa-U12708oRu_-M%ZsU zW4@ldBvD9Q=Uz|IQd_M)`V{OuVio3**#1f} zWAbFL(!40zJ4KTw0o)Kx)4O+$C2JFXGF3f@O$FYCt0_~?i=REw)3I66VMD~~McOW! zq50Zal%>4!h2qVht~Sx17yCyL#!^h>Y*!9ZbSNR|v8>O1qfRYi8MpU}Q_u!bri}Jv zleM%M6->hOpsTlfC!epyCmettsM!bv%BoO(7#tA&JQ=liTkf{O8sCRiI(@0&(@zj8 z8IBD-oZgw8L=UK4$cN3&w?G_^?sMfFQ;dW3Dg}PEEjYAZfBxEAXXor!%bfj2J#$bb zEHpIqd|@$A9^Zw5-66L8bzvnWG8iDC$_V|xee(-0Xx%=!E+`*SHJdn9DuO% zZQ2bI$6zL9r2stVyoA?BGY}r`K-jYA!xbH%K~#xsIjsOezyyAWTYp78BSWt2cm;FG zQ*=Kk$L#zFeoTHA^ZK*Z@+}DGpAIa+IjY2YM=RxO;Kd)Epe42`^26+5`LV6T+<0q( zuE|sndX7br6p}>KV=>P-c9>kbe7azyjS|{Z6mI=&f$O)?)~`b4{+0qvPBOJ}91|v6 z5eQEpoe3{7MV5yuwm-eZSnN`=vKTy|3WqC@LE(;onIzQz_B>gwAMDdS#b@N!zrOe7 zi-0I0Ve_RavD|ao0df!d^uKX~wXBJmR0H!_FS^j`Q|kWN3Ik zsc39V#CG(Y(IcCO5Ojr>f>Ap8SCNF`R;DYFDyC2diTCH8FhQC|=n67>( z8cN2$dZglwwFOyKHMC$(7-r`y0O1r;fIuC81bXr8l*=u&@kMpfW-Fn~l4LkRzSxgp zsScN^i%AObD9{NmBc_L_G7Xi}6(c2)uVxD=m`|PhmAn*QI+<2I} zM}}cck=|hl5|)*6d>0O{g+#;P8;XEuzwMou z{<9O*j)~6kpRI(zO*tN*UHgGvZPo4GqJK?DzT?Hol%!@kU~|*s)rkI) zM7PkFCO&lVZm1G;TiS zT6OKsx*g*#Dpx26Xo0rD*N9_OxZg{hHE|jJFo=&Sa-Myk0GiM`EI>8?_;5vf%0vd< z9=BO;zf-gkg<6etED>KK(9iM$q}4y0f;vl!3+s4jb$Mqy*c@fL9%i83Lu)4=c|V~UCaOwP02XP8CC=K*+&bFMFQYd_@tjPKWQ0b8z2M*(5efl0YQ zgpfBT$(OZE<^|&8w}|Y?$59?=TH?jk^vM8xPZ0FYw8+Tt#Ll|`6(&B)gT$+#G%y9< z`=aqSn5^QY)|(P%{WsGEhkK(DTXAdS>3%wnOi6_fTT=5H2$jG}Et! zhCWvyO!?BMBvo7#b#K*NeP+EhA>kdr_2cJow?=@3H=gYvgtO#&dY=o@PS^SMcE&@k zfS7k(0>;Z~y&3Z&*-g%8p#uMw4VL9evPXm2T)JiS$c?)g%_I`Yw&#GJMn9Azw6h2B z|07`Tprfnn5(nf~Uu-F*UqO1$aJvv%*9z;Ds0P|ndHRXrVW%Cit_|Zy08z&4kjma< z-+ttt*83$47>~>Xdh2Y>0wLN(HktjlEIT)F?&BtYR_xK{bQ5us*#0U8R&(4_&4Q002BQw38cU zK{FnLit}_c^F-ixlt^*Yuh)hI|FA8r6%s#}2J)}rsN zR3SkBt2(`XPCmdfTk}J$1F~f!+IsD#8jT8(fbbZJ!e9qlpwS1+jr&MGtT&3jFRihM zzG-#fnL#~Bw0U{>=X{&K%YCaxeVYR{W97-4EGg^W7CsO7%6LF6gF2^t$h4;+#wKRU zF?2CSeo4ls_3&|bbpHtgkIk54ur!s$4r9Tr7o(unHy^Y>nu1Kqo(Z0$#c7BAV$Im7 zPxbXjw^qN-H(I$6iETC1`q% z8OqMp2^ZMG2t<~l&B+=ykJEB(R5Oi}DyFOYYHw&2Kog&lLIaLHA0^QzeiF9E&VN5N z>>m_1H-1G9W_RT<~cqE-fg{*%SzByK5m_d)-N1Dr*T@=4|g*khafB?5S~V|q}R>0c9s)HNF-@8N`Cx~ZKEQR znyTD##&jp-Q0kyRe~)>H!5-s|_sHl;RgMAEpVM;tfQE!XM7Uq2&ouH0-V}wW0oOd= zDt*0WbGVFy{Le;K7bm-&G(aK3gkvHGFb59QYi>L(&E0wa(Jgp4A@#biI;Pog_ z8jL>uU7BIO!Vls|00wh35M7H<*(U}qKHkx4yib3g@REz&Ihj(+haMA;nA@M`-k84? z0F0N@4*YvH_OsQdPPTH4KR&tzVx(z}EAJDBbPzkXb!-D;D0D5~Abxe<=5mq7GjVPD zOz8lS1#&{b!$Q{*n#~t$m-R896v?oX%8&1RoBDA5dAIIfMaV8EC=b=JqzOj^3P;vY z2Alz_93`%z255o7aIG1l0=ou}=kw$toXo7BAo!I50f4zdyqxg>&06e1PflVWU7OZ1H<>ignc|uu^1#HY$|4+-=Ti(?Y#puNZ#drv^Itn7>MA(nt z@sE9C*0=8c%4w)!UP;O5DCLY|GM-fV0x{2m3(Iv`)#&-#`{s$xk;wR`GGJ!2Er;y` z3z_*q_u3pi~PduE*A_J$xv-V#I8YBmpR4Gd=>ablq- zB}M;@l3vErbDL#62-+&h7>t>`(_Q@!iGCnZ#?wLr0YDRomzP#j5XUQ43!l+{)DQQB zGq1pq5R@?=p+pXi6T1^5!E{QbSV{Fw%;zHek(H#RY-%!{14jm-sI_h3ao-6J1t#he zML==|g$2sSqXd>~V`uJ8Q9(H>z?|cnp&`-)6GMPnOP2;6bBc-=zOBrUlp$!bnaVJV za8&Y6Go$>95^^&9E1iwcXrlUI0~XNC#GL>m)*M>mT!wk&2^+L}cFW<~M~QAgqCS$o zjeCt(u!kB}ws%o}r3QINd2U2l#?c<>oi)oG$4m|7oF=~iN@l1W+&B_E&NZYOT*Bk@ z+(>sd4TU?A`|8btvX7&Y&nO5OEBEs$j%8zU+pD3zs;h`_h~S(f`|f{CVsmjPc5JtW zk(Fuw5`*XgW9ZVmr|mCx85p|Etgp=X!KiZ7Kqjba)BM7KjFhE8-B7QZ)i?Z{|LUta z?+(~ATBAcG&QNeX3mO${I7nPE67-|62Z@cpsJZJ3(1OzD$g3dFyzW~(b|jH$J=PLL zqG=w%Ro=CHLuG7JW#sDy4-Zct{SL9ADf5nDrH*4;-2gQOBm}1UFThTU8;^{>RX7<>+b;lyoMB2Ml8ZN&4@;IIACS_>f;8Q1kz4ARr0v0Fe~}f2i|a+O z@YV6qWoBf!Y3$HhRD?a-5zP0a9Jy?1z@kkoz@nXC$hCou-Cf=>1Io_& zC`oal9iO+0I1NNRp#o(a-^mkRitfheol8OoPHn_-iA#ZaMqP@R$j%92r7_~86ov}egFeA8SGGKX>~2j)b-%wLCa z72qI;^P@ld!0mTL%ZMH)1c2I%&zB5(dyKvL=j;sKwxn45pDrOM6(s08tlEA_>C9UI z8E&hlx|j_cAJs@;$xNJ+xP5VCN}HAf>WBwaKLRdx#(RAJ_ad*fxNknD$GXe@y!&RP z5`dFq5Ae3IPYrp)4ujG`IBz>{`->MGG;TL4nvH!Q8X96JK+t#`h`QfCr7+&X0cN4#TI;uQ#c9&7_Nn~tLA9w*L<{eJ!sjG+F7 z@E#KWNAuSbxqt!TL5FIxkuViP?w2`_o=XVP+)N>c7>LaB8$STOSNv|VYXyyPR_YU| ziKiIi)FSUW7^GKO0t(@ZW}!9Gpd1+09CWn0kvzBj?7SJTH`%uS!2c*Yhu$Du(R4!p z=x};-1Eiy&0t4*+5D=%B=V9c2{(pu4^+?x6029pU{iH(XU5fEUn5dMNq7OI!je^)S zKV>%3OrbOT5jSAsnv7Po;Syca_@(rQYG**oo$rsesH) z$_(CBD6b)&e~EmSD(W%(>6udhudRSFb{OJLI16Y_G)YhcJwN*mOw!Z#KF;UhC&wJW z7#;hwxL+v!bx^=Uk}QBz9!dEfLek@I0qZX zTQ(b(E7~V2p#GEQDG7(+PrI}_`sKA*V;%$F(hWACv>kCkxk)s8z?lP#@c*hFQT^iZ zJDMe_fD$!n^A_Q})j$e_-8DRnqHh?fVM$M3@0y#N3;L_iVr5mMUbSH#Rv_3am=A~V z`(y38|3fh6A3wQ8f;@=ZHX?32?)SF!`v0qr$`sirYX}s?O~?VAb^#i_0~_Vh|J9ua zYYM)?koy4*j|~K_^s-}nP$52{RD+FGkx{9>af3TB2JJAsYZr5g!FQZ*1LzAx!J)eyZ1jT6xle83V`^}peu^kNLcGb3Woq^_>+uS-C1I7rE_yngT3B>ne( z-HGt=UtGIbU1f+)PUSxF4PZErJp*PUJGO59sh+sxba9f-7wm55dK((HUBou25_Lmt zbrIv*DHQwKom0jo7IZ-l{h4FhilL1k6S>nW2o**V(`#sY!U#J_B&VFCy2{e+!buHK z_y4*Km3)d3a)-%(Ij4!~@5EsBoOw1xabeM^Fgou~UjUa|Ljb@81b*$a4`-+Ti@0E0 z-G=9qo)w9l1PSfeg`Kp2xb5>X&seGb{I`*8I)vORC^+zTSlB5ayw>&Kw?g9tkk5d) z4dg%z0)XwKhbn_`mz$Lcz1_r477=51u5$kZEg&njVG5%9SnVr&E_kC7snCQ7vV{z= zn}yEcpj^@B(UFjh~= zzrNkp#ncZXKK{B+2#C4g-O;it(DKdSw5#h$qkgG?gHuZD&A-;`dSRFiXgjDMATXqq z-5Hpy*#2{^{Gif`j?>+-IK0l$KNGH;sx5Y~ zO^rKtr+~KTuWLbCc0)yaeBz4XDI10+*oU8eg*E3EGyYQLW}b_4}>`W2Y2dSkS(r7 zpiL(Y7}2kb@d@)w=|9V!`%UL^IQkR_r$9rDlen3ki$?vAEx3eMA=_Z!yg0wtL3ui z9uJY9M;nV9t_IFal>cMIlQH7a*ohd*mbtd#qBxstA?UgBwZP$|_dD~CAO_tZ={B!7 z0X_m~If`=a_L39i<(EB7B72G0N6%Tv8z>biJI< zN=>tmzq>*K5YF6y)9U@k^N~w-^sS2a@=IrJfX6VNp=eGoI88NRDu0T@ zT8NJGo`QH7UE$2w`au+F2WrECr;~ET6SrxI@LSzD^CmD@-&*=PuDtOiR0&&~2kspF!i+NW{!i(r(=Yy7|6yu@A@x^h8949AF zDnnP#Ox9Y#{Zx#P9!>m%IU`SMsKqDa;t7aI4uGM&g+dvrg`;1eJcxVh6Mx$#z>N#u z3lBKB;^O02e1?Zb?;6@cmbsj@-6GRHL=j-Hha9F@0X}XIl0|6C4^ENj1{phO;6_b~ z=a(|Fg$m+~b93WS-BMCi--)TEx8k@=VXgGs&HNW1BDv9vGQkI6H&2&o+J0vW#(cKY z?vYS_c@{1aoiMlX!4xt)WP|#2U{-=&3_B$=rzkX+%nO zwtm4flJ6?C(a#=K9|A9pMtBxaXay{H*kLvORu)v(oN%`z082`-xXUWD8jbH_hjmh+ zz=+3kf5 zPIOoC_?~N~$CL!#+0bAsNAVehhp@VbC*}=Xi&L0-l{bFD8&JwI0K5BhV21GH_;Yaa7>$d0ufA^9;YYp6ySS5y zde4wb0uuyA0^J}!Uk;3~MHz2>GR1{mqB3|x9(9N9AaD#Ojnbm-G@JE*Tw ziA{RsJ^gGaY^>6dl|<-;I(;VCf;4RQm|Yi8n0868E?)62tNp4!{_Qn5P}q@Y)U~Yc zXc?D8;&jO8;PgStCEm=j_?fh?jVz~Yo?pb*<2p_wMVbIx#;;~XnE5V9+5 zrJXk7Sy!on*07%%8hX^8)oz^~TF7GNKTC+ylCA2KQwcOGu%Fd1OvZg2W)C6|Dc9d9 zvV3XTnbmn&wQs$fFt-Lb4#Z6Kk#v|$_m-UB+I%;(rrt1p&(!|0_zX=H{&sLkeA(TTK+Nk(9<9s9zP1Q^0ea@ zmSYr9`4huRF1+FUgXq!hc=Hm$c+p@)&ylIwfCwdIKL)dk0Ws&ee3&bIAMiQ@WPdk$ zuDZ@}sAgq;vh^^D;f83LnX2YhPd=H8hhQS?##^*Gr-&&}^DJGWeAy9L5nKN%WQ|LW z^`n+)zWoK9MYv^A=yVRwB>jppg15HILBcabcmXI?ltOJf!C*^(3gzdkop4tG^e^_~ zcM$@{eoHWrO@Diu%AN2FpOguVfn-e0^@KpVEYT^w{Ojw^-v3Ml!)8~aT3&_>EgH#U zRM|jB9N2fIw`S}fK0F8y-0`9&m=^lNRwM(QT``(6K)#N5npZwNj33qg_pu|tgG=+A zPhN2$6t@j7kxN4Jtf$CntEp`vZwUWZi%IzvbF%`vXJmLkR6e6|s3Zaeu=lZJ$(B5Q zZmcq!4{GclOMd9+)JPmKy|&U-Tl?&MKdyQE^#9d51iGc9g;4{kk`fglld*X-rL}H=z6N@JME?{69sCOUm4qOyAD55ew2eoV zS4J>$QljjuM=ilblk3r3tyl?=`L)CqVu0UPHG?YiYzv-I5D=y6-o2YDDXpPkbhS^v zddKcvZ$E$jtj8Vltj|BVj|FztTiNd%d{f-sWxY1UH>S|``@4Dgz2+uRu7sHmHPt;j zts%lu>5J^oMzlOG5g;9~>C7_7j(lFal8+c#lBqi7joqTnsIJpUp~I+@!C=BlRX<5G zw81za{Zj~-iunmJO8dl1ymewMaWdD4dj2eHN`zP<%00QBL|?1hU|Jhb^YfI{PJ}%o zq3L^f`yBijTLt;rzmo8?ao`Zzw$ilL`fBt=@Z{d7h%!#N2L^s*Qr+hQj3C}T_D13) zEFFIDx|mb`B51otLITaiQcGQT;~$`HE?P#G+%M0?)UIum(2q%iLtFb6r^|IGfrW?r zjNZB#)vlCW|M6y=tP4jxW|f@m3O*M*eegq6yt&_<)1q=7|A0>~q{?b(jwD^N4u8cZ zEx+R>w@7GO3aENVR zb}+mUJvUnuQeD)ui1aSE4F}0bJAH`2JZ5-vW^QG5Pu$&ul{R?ZZ0UAIq)>q`yWjxG z#tT&B+vg-*#!fZzdcPI7buah&>&<6rIBKen$yCQ4+Pw5x$w_Ca%R(`3;krZinc6Gc zPV!S#3#xo$E*K$)NFfWtlI|+#u;kglW$u}n_ID8ie%uwbyET$28Qtz7emMLZd16V5&eGSFSA{V?iL~?os z2N@7^_n2oF@h^PHJE^QFr(5_x+AA()pUDqY{X})@=_TiY3ol#HPBDGhym#O;f#&h| z-9%mo5;F+QkY`5kU3BUd+XL zyKt`6-lP`4yjC+d%cITf@qSx>paPVWaKI{v|1XO^yRt<~1n3cs%?UpxS~PG$iu zpop)r1*z}JYhlsBt?oV6Yr&w%m0fm`m0I8iSw=7kpoS$J=5C9WiNedrfZ73!sI>^? z!$cDE+mCD?7%Ia&PZGqxj=d>kqwR9}FWwKte)J-k3QU3V_(=Bq<77g5yFxeIR`rg^ zh=)7r;<16BQ&v8$=Udy|%^IQ#2DQUUGM-6jg@z;$`ppt6jydznxHyh9bZ$r(t$pd_ z^?gt}eSDh(^_*oapE;w$2~GhXR`&T-nO)p2+GRJ1`yq1B>DM_Z1HF}DGUOZ!CP0Mz zl0*{TI>1fTe#p*9p5g@Yh~G9C4Q<{!eEn$@Xf-9hS^cPz;-JQi`ewNtrYJV;A9V_R zD!2_s+XwFckkQiR1D#Y0%~L(D7T~1RQ&{s8O3dKS(NLDoM&YbSGt4Ul@C7W@24yRs zjM`Uy*`Y0E!e$`;h2q1ri(A9Nn`;-cwTW#gnCIQzye7y;bGg4C>bHGr7^Po-{vEP7X z31FUYiB%mLvjI&lKGzV$NMpXL68UP{cGU2$^ZZ05j$iUf>r5~1jDRY)dmm{-_vpu* z;KpGXTX#vLvwzObo~2o)V{l2=a1vdcD2rmrrxO}}G{aD9SXdb$E3w_BTi0>ktev@A zm@&8%Lmc36jc#wLwM&XBiY33X8eV+sM^U z`{Kn8Unehq0^cwa?$KOn+UfV^DgR}%kG(MRQ5FKug>V9eT@7#Re~?*!CY$W1J+L*@PD}_wm5AROs0}WJeQf)@5QMa)}6dB z68@eHnbHjp^(X`W#I`R(wL~0!{C!JfqI{=Z6|Kx@>skdLc*7E&r04ZOkWxe6VrG)I z9HEaM&8tSfMpl^GY+fR+zG=YzZO~rPm0|eC6jS)p`V*Rm+DHR;bL-&;h80n9@!%#Q z^>tZ{xHj!-6$05J;xut#DW8t{f~_X)#aA9E)*pA@9F@2PbxoWIq048a4NQ{nTsaIz z&5}!kl$gNpWN5GYuCA)3V~CYo$Q12Cyqe7DHMF>1`j9&ak0oD{YVNv@cJxrBD7gw9 zItV@$AWzgwOfcEi0tuTp$?-3Gnb8IeFoaLjwE7?&FCT-AxTEe|7E-qe8msva77$cc{Y>w0FGiZL>fa%;+VuZXiVs)6FBO9Lf=pqF zjEVW24M41fxnQJ^71E9jooS#3RuRsDbG09Z2$o(b!-RcSp%8%?jdQRlnm)B+eLv^~rrgyI%eP5)kYSxw@aA^93#>kzIV12fN($AG(j(4 zzI^R#JZ^~?%74fQKdubEz_d9d@L|&_MFq9oxzNF$t9#oaz%hhr;pIl15`ypUOhuUp z8$~7Y$hT30oyOo`wfXU@eToE+TXmuRAI8SUs)umxk>TN7VWF*HlDm{H)mJzBIXKj) z#X6tmaKe@VQ)Z`JZCN^bMjh2~H-hWVJk&Kb% z>2|bjxSSJ;%z18mpBQbdv^jT$xmfJ^U)ZnCtgnAkdI%bB@f-dm%_hr|0a8~&hZ^t% z;o%w^Km38C*S`%dTV;kW)i*Ts@=-oW{)LLnD_*w7To1la`)F?Xf{%u`PE%ACT#YF{ zW4xl0;&lm==*&8_aA0!rlbCV&7+^eNsWmLL5~LrnEA~D^p3T^F#wAZ2CXFpmNe!oKb{(QQ=TXD1bWrd2I)WCeh5R(=0%D9-#C^ zOiv*_GYoA?<*B`-h({h7ZF(c@OM7%rsstkpwA$tv>R$Z}`1nyl!iS{#ra*)QA#H(x zclKhU8Fpu7s?#dWKcm;Ekx&hwA;6Ef?>KD07^>uwDXa+Wz7m?5?$A~m1@Aco6O;kX z7g>19xCZ~KFaU__jnBpO*ho6w;ka@Xv}0-&$Z6u`d}_+L!3t0<9z?FHnlB$l zMIss@t989=2yb~TEN!d9T)9~C;Ogb=A|}&`KG%&^u22z1`So+E{8s9j$n4 z4Xv3$UY(MAckC*Crr%%4c6?D^1}^aLT@|n$N?VUd-*X zERwo1f%3uS4sxiolE zgKe4sPtoS8h*co;i3s+UnigED38 z0MLO8I)x8}2pyg0dC|N<`DvcIj)Uqb{EvZ5?IE2~J|aBpByBrVC-4Ph)WnHym(Rug zprVLD`$A(jWv)am5dPV9M4Jk@7T~ zyu(nB(79LcMwqA+acc-@j;B0P-M|i zthT>@E-$ifSPVVaLl`(L!*~fPSNc*-$cDCW>l`a_8uP(k5@weN&k2Z>PCOxs zsmURZk3(hAc5*(0asrI9xy)lr>v(WXaw!t@MXtDH%q`!U^*varlItq1DCKz zu5z#3hNEolJ(MQ;%D9NLLmP0?pok%CE(PAND2NSF7j-I7Tsz?(dW3c~*K&37l~qZ? ziy<=A=adI4Jt{mj60A19KNoY&GnbO!sry)$#=Vi-8WMX%=`J1YoZDaUhxcC;WE|GR zu@J!VArR}o1I-s;1bGA2ZhHPC_Ndk7y(|j>T@4t25;+-r3SDDk9db z$6mu`#VpDrq8X5Vx}is$z{%;8_Dy7+3+Z7FtHuHAi}n=tUmLK1^<(J(2#?W%m)-+}~kRJxXz z)u6U!@8c+qX`{bKn=#>DN*3isq83}H9bL<}an3x{J|O=i(T3g6FRY+EFTiQ?DvsCu zNtn*}n5;rqm#Z3wYnvRC!>?~xl(!1+TS;M>r}JCI~Rqy91gVg7Y7kKlx3Q7?bD!IQk#~K9{}i=aIYpT(Y`dJ@Ew7B$WjC zX2YpYN?3rYvG{`AW-Goco-(8M52#CJr|p1xDf8?ZLF~X?{F92WoC2P3tuX{_mPQc! zKC;}wp-tQ{G)c+=iXHedz);4Pw9t!}wV82waAt62I*gRGWK*@U4gKA|=udH8s9Yzp6%M49nXCvp8H_+kl; z9DVt7b$7%y)w35r-glhYUv|8n&|(04dHJAD$h7whcaUSn^{Am(jUV;H4jNgT)6D#7 zl#Y)Zop1xk4^b5Me>94jKhRDS#Fu}P7%xzotHj*vPn@L8l=AXC%P5t-V2A*M{D zC-)Bq$;#z#c=*gfJQ8k(6uf-g}lKmIugQIdT|PguU<#va=|4Vyj%PgI@!{Mt+@ zA?htc=2h0cpWOt+mW=y)e;BCRGPGZRW>%H?`5pgO@?aOUcpph=J+RGLO3ZO)w7av1 z$717l7DeU~v%fmSI!JE+dKs$P?h@*V!s|mo*Bsr6W1#3H4rohI1k2y+TQ`yOVDdX+9Q^h>a_$T80{QPRC z(7GF1gU)eGP5eYSu6;FxF@&(~Lta;2pP5pr60L0UO1*pt!il^`q}&yflWf4+h-> zMdxw@^=&Jr=HFlu?bUkOwJ+U_0%i0{=r6PC>BN|+kjAS z0MPV&(>7*&BIDq(_Vw%6J|1ASoE0Yjj-RlVO4v*3gQIHbO|?v+)y+}zgHk)y${qKY%pT8!jWI@3uu-b+7R_}UxWyzZ@;6o{!h--hF@ z*$cnS*=IOC&dRLwu*c>D?Dcv|qK2*1QWGr?!wle|ulwav!{@yte zS3M-JE%<@cLwf(!baC<7*P#0cq;R+i@*FuL**TEXq$yT2}-Zyr1 z7p9D+@yN;Ld%ihSNBp~1eOLJ+ZR%lViR24ZG1KKU{NPV#z0vjQFF4n<^f!B#58uqy z6=52xijheaP9CsIb4RgOSgI3VUo{=uh#!d)ND=1+dZ~ zL+|?-2#s3zOF{WFGGex+yblzX^?+?V@$n9+%2b6|NU|ud2%qpDR;8{;-3|Lz5wA4} zn;O@IjF1$Tz@azY(Jf~c%b~O1LH^+&7I!<8#_<7D&R8jD{Ngk4^@A1iqFJf<;s^Zl z!^6;m@9OeWla>O~3=(LfF2x4xXq#YlZ>>Wd^o5jGe>gTy_CD53^Z{a zB)65+Z2l%Iu!bc7f6_v-uRAoM&~@8Gog)M;7EUf{JUpTh`?xm@J?HQL>aN4G__J1? zV`1|r0WCygC2gny_#PngWrSRMnGZ9cCYehE_$!5c#EgjXX}I9#>=LKgUWe$%GeHY! z;2Z9{X)WLnNLUHRZUHZ*jNx0r9W+#ac!XrsERdeysRa%$;p){`Vw`U$x}_T>J-TNP z@I~&!hY!3#_wjhZ{lw%Zv`AglT>kRD3hK^TPX@GMB00_S$qMO*RkdAue*sRpf3^xpiC?wF=*Lph&;qx{o(FCn z4*GjA{y6fDqC*WfF_DpLuI#{oh1rHSlM3kGOmdo7<}iAMp7;eJp<(Tm$NGN~gqVBt z9_|;Q9h%9~K-kJWjA8~9OZLStHUrnSkZepJk}m_lKan`<_o(1mH)5ze7?=Bz(D$pwV0Xq%yobLpsTWYJfd&0rs4Du_$_Q)WpbI_=ATwQCm|dPMi=CIl9t- zM~PBC;I5WPYnKJ1LP377R**H8WE4H=VG0LFe=6NsHPg#Kxl-rL>oOe9ea#~G>@=;B=)3YdLY9S}>blqAN;BYEAb&_4lpu#T z{`pJ(u=e)$dWlI}j4+XQ-1mXgmoCPEh9+9QkO!UzP~@(#^B;9>X1{(Z zp?Q_!v{y}An|VWS5xkn+ShDZ#A9VEUCM48N2kgrxV>CVmA5;!D9seP}l05U3X(2z# zGmU@i+hZq?F|!!WhG)KyJ>u5l3&bNV%8|TiAX8=j-9MRwrujr4u-+B7CQ2(jr}%nZ zqxyaEH^C0Ym$&T)K7)5v4{+ikk>>ctd#Y^B->?+3%Wa-!kG1iasbxv;jN}V4{(n?m zcOcaN|34{GiZdeN>g1y%wCtUeqR2RuT}efmMRq9;Az2N3WRHds85t3o6{3u@PDnU= z{hsebzrLTpzMs$c+`Zn<*K<6ckLP$xhFeVMp(nZcQQ#z`Ywg;F z2#p8@W#6G8`A{8~5*Ax*Wqj^rojs{tyQ)lki%)QqlbJ0i^EUCS2w}^WP+}@hX)Vif zdgAcm&)m!i04oL~Yt&FFJ&tYEqxI21;{a}nt}5Z^4Bg#GmVDJshiKd<#&na`bR!z{ z)s7lbv50Ds3PW!fsP;ty!A^Og5YNDw`r^YnG`3KGbo|W`2?y6Cw-+pb*pTD=vY5`2 zB1MLInVYmC*K`lADN$B)%VIO1%77^n!Q!Y|6GW^J_lnQ^i+mVgzP6TA7%V))z{zQ7 zpEMV4F-nK?Q3h&5OJyGyhqF|E{S{Ly!vq{_&dRvG@;$PQuC!f>X9P0bS18*Ij@MGGvbUJklP)VVD zKFUw0e<6R>c)&S?rQCmx7Sv^`WZ)NY-27&}&=|dzAn!4$o@7u~>6^llu3I9D4NCoR z!ET2pd5{^#Ns~*w~D%?)<|ARjVrjNQURw)X~xL zJ+0R1)B~j>y|@j`LrnE+nQJAJ^LsSWO%Yq@IvYD{TeFTKH@IROv}_ZO(#xBlWS7O5869@uwo^ctS0z~v+!t$QZpM~8N0osIe zIs=awI!btyysU!4k|p&d`dRsdKhbvYSjs2ulQOvaSvvTE$={j`>UTdM?P`Y7O$Dq8*orIE|M+-G1JV3Hy5{hGIayTpslRLv-?=;EUWHIJLA zFVs#LU29r>%~E;OI`j4!t$Ju&c)>MGKTEIdqL4Zjz_I75LPC}FzhwZZhl*uO3Ap8$m>VP+B3keTg9VRqpul>Dr5HHHdTmO(tJx zSE+0FjeqMgb8lhxL*%C;%u7Gkj$=&{t59bqp$3vqOzyEM=wg~__3R8%7m@PpDu^)Q zQSk`Xp`#D*-A-k}S_x_$m<|l%%+Fq->A+W(HDCf4Gn=xFLuW!-2B8bgXcwJhgCv`P z@1p#IL8<2NY@~YhPHj!f%~X8*tg1$Vu!zTWFvf)(pEK9$`LxN2Zqa0XnrD<@)K`B0 z?q)5E3;Fh6OVhWylR~hB9x;SX1judspIf}sVNC6-^?E^PM3awc3eQ#o-c8zmvC?Fj zWvZh++Pp=6&e@+7ThL{?BC1U(1u_M3S)=u99K?dp@`IoS)GPd ztE$6MZsGi#i=&3*d0M2s`hSoZLr*Vm>b$H)mg=DC#~x1GBcJ7J({pg@^AXSyXW6e99Z zDbg;rX8KauoBO@+pHXdbHxqWxwtw0QCMEA?xF9y04PX0NKmwkd&c%mcABE@?~ zOZJI_?9@OoT9ySJtV@tRCjA_f_U#6frlzKX{MmT7ShvGH0!~)O@{^+o5FiVN;g64AXpJcPs#4je^aUJ4AzdBL7W>A^nj;;300%PWo1oiH*C{SJD)=CWd%JFvam5 z#=|_E(fN**LjTgh<$3wL{ZQdC)JZbcNwA@;T#wHv>qSAtZJPo=oqH3;}RI_Uco^)_JdUa^s>G8hL zIE`{tyO8ez?CVZ`vYq-nwVpC0CEbBSNmp~Y)IjT5jZnSc+z?S;sO3v(81^TDP>q4M zInDY>w#o!K!Rm9<4)kkz%yPNAZ$MZ-t-dCPy_42LP+seun~dW6`bPc;9PHR$7kB95 z>f>JE>r*vbQesDYKGP|>33skp$#j*?;`^c{dSQ`!lGUVgrBe9;t-IzsY1L9gh9%l1 zT8+C*e}mskyUuG9M|-6Xu4}MO6q54e>}S@XQ9{h6tvPB>k(zwZNPAvMlT)TZCdS2I zQn6;-8jDnnoJhqB_a>n#n}Vj21E8Ws`=*A8AS@w@WmxPgMN)Y^+B4wKVvQL-k^j zDE3Zz%NMJ;oWa@x{rc(#-`x$eakZb?f2HXe$6`x9j=nQBX&+etACVHq$-Uv%PF%Ty z82a@)+fAR@Jd%?EjQTafN%E#6Yd6DYCh9}>7+6=`%U@qK48Igyu>iW}RMYr<-^I7h z-=-EcFxl7rmz80q@SLEzocT&KtrHi6SJ2JFbwjE;NuJqT<;MPvYRzZXefxYqKyl8Q}b(BVhtOtS^_kOE2{AB8SVWV7c=?)og+^Y z3eIY1+>)_(TnO#P$ldfDa5Pj?pRg+E>Iv(o+=1TgnSDLdi!DlORQ}f2(Vw~G|D>K|RQo-A_^?vjG1j-N_@j4E^u;$aV%Og6-SVrUWNd9OI!7yE zu1yjL>&qS0i{+-zNJ-m`Yf_tOS{>}OTjxX$e|9_LPxGCled?ZVYt+iU6`G8G`tGHt zUrsgI(p-^rd^*=#s_DT6URMu@aMNs(4tnI0n9IE{Cnc_q|DKMHG47F6`AT&@zvWp_ z(dU8nuH+6^Ib1SUKOGoQDhfIgwgu&-G5xUn6;xfJO1%-1&&~W3k}n4RK61FzSZb;Z z>3PYjTvJaV=|~T z?nU}_Ufky7)YxbIt+U3Kd}MkZkV^dpq%GYhy>FX$4Zwq%3~W3oJ(1QCe?i<+9!Xza zoFZ+k;X)PvBCXAbZxMh+8Ue=gNSTxXW!kcOJ=K-7x>EbGaK0dz8z>-8A z)Cz<@H!P=#HTVeCy`E{pPR+oUJZ!>$pI%OwbMQs1McyfPKOeYCKs%H4BVL zADpN6-OaO{z(%`VI<@lssGH}|O5FW9Wh%K1rJSZddUXj0%h9*Zzo!>8M1LDcV{d>} z<}PrkGI(jEV!s=w)=>Jf`j2e zp&*u*a5*t4V6WiQT#m{91@dS{m8gr`OvZbI~<2z@=kgSHYaXYO>-wVi4Mlyx0Tt^u`Z;58XM!U zw=h4tZZ-X2MXR?`BEZHyP1qL}&_@$Lemq@Yy-$~X|NjdM9h|(Y-Lgz_l;(Eens2?Z z!MH&MH0tz1K9O?ULcB_Sv_H%0O2>(6l6`4qo}13N=cV2&@1ks)N*9{rpV1%u&W$+d zT;TZz-!yf0e#QZprJ45n%;-X`!f`371xEfwN~K?V;Rp9>1YjJuwSd(@A&jWp*(_c6 z)enYzgBgqLF^pvXOKs1+%3sWW1UZdsF`;n+&ZA|Lf%*b%^aWEchBDB`3F1$hP8Wa~(z|jFTTwAH39B%?A3O zZfRXY^@x@#$oNb&y~pVO@gP5Nd2?3t`{1SpNl_?6)h zdfxd?=nsSBp1w>-WwDRVP9J|*=*=sPFt=RMvfAE7Rp@dM=e_|kSW&=q3Gpa`G^iA4 zUh5)|{cE(7FcRFIWp1VYT-z!?dL6R;Xn&A4fs*jIZe9M7Ym@bsGz<*v>np$a<~?Fh zwIeg=R|i#V#!|I!<}gP8?b7L6l>hn<0VgdmxEwd(hh!cmz%ufwIdI~)5+an+!{OKsN*m6tKsD+oAnk<<`&B5AyL^0IFxGQd zt+14-_#ZN_<*1AmpL#)E?yDefV`#}di+g20t1wxKsfSwIEEO8=|70c&{kiuZ&W?%3 zy0^7m)wphhcT)=w%6VK`d{`j6Eq-p3(nYZFl6Rw;0@%T1i8QKwhm*R%^lkH-yrzdP zEzl1QTh3-2b%x8jxXt@BT-K?4YpKy#JpLSm%V)~+@>nd=tdjc*T=^S^s*G^(p1Zl- zrl;xG-V07m^IbX>7&PxYvsk6@B)(D``X#a_Wc%Q?6PEUBIbcgB@D7*dj#|3CR^qYj zfQBx4-wHvONp1SP&hc3Z_Px93dOX)cfVeA$-o{vrl!tmq(2@iTWxm7$_^co|ZC86R6E$~-wsBZy-)CGxE)FUzmcPJOTu18)^}$rU z0tJKiYU?+Z1T*u@jaSdY zdI-IIc`6*`l_DwUs4Sdv8cA{7-SLM%9gTvX@Coaxif&v&^G)E6k87!G$?DU&*c9s( z>q83b6-clVU%L+UU&{s10~3HUF4pnxe*~Z)mu`0||J2c`8+#jnAe}IN*15Ga(V?AX zo_5z?lxTu)+1qG0q z9tOQof3-|{_UxHzQtt?_ z$NZ|Q>WxVf@u^m#vic%+ErGEld4rSgz?gY9XU%(@}guJU2-s2rhbbp^>%qNL&Y`f3;uiSo4FU!q#^ozoh{X;E#d3O(785(9pRJE&T=Gr599Z;CMIqj|0*;3#w50c_e<{hClGw`bZ~x#J%)OpGAodKHd%8ro~RInlA_NsOX$Dr1CrWnqU>jfWa0t$nByN7`KmL#A8y4Pq zYh+zAhD@@D6+(pHabv?|PdO>6cjx>Xv^3{Wb7zu*-!5Cvdy!+e5GtK%B@ z-P=+wv)`d2FIS)Es)7hr(&B;!K8WMcD-W==vkgs6KlVsv`x8~aNtT554-*VZP9=)5 zqjdmEyzzjWEl{lkgxfu*eLHK1wcVIPGS6Srmb^~z+8Dj}}stSG|rA($cLl*wLs9U}=Q`c~Y(fZ@yHI$$sl_y6UB zHtHEEzg@J^jWZe|0tNNR(Oh}L*V&=%J$=#7XcO}&{Rl!pBT$T>K3ch>O0z)7k|D91aT1enNNWdh_+p%; zSl3S77fBa3Jpf}=v-Bl1-8&T6Q02|G72VJ1J{L3;FqzCC9~Y#H9+>87mn7dt)ZqJT zix#g#a>;6wN7+H7;;`S??M{$0m=7FMyzYOz!H67Z`?t{1c^M-j^tMlXFtT&hV-O;#N7eJ+#iXjk%mS&K&_J~f*I#29$o6ytgs?{?BJ?|pl^6*z`hSEnyXkuIoZ zTKX}UNkSz?{s8$UfNdFvswSaVK~B~oo?1S;^bAW+!nooXlZzJnq&B!H@~&XknYLHF z!J=%hmT959z4R@v3$ldg^d*qe<%$q)e)l?5GVOA9Q!~^3-32oN#C|`FP|4CNjt{NA z+fUN5YrI89GLcz}zQsnmf8e0O3}NY5N>!GLR<#hppeExmNn&_-_i8?ibzNtP`yaWG z{(>-r{uvPjuuy3A_I;PE*-1cT)R8c{?e2Pn;;9ChHNHRB(IJkf%15;vjhv*Z2br}5ZDCPit1mwEz4c|vWzYo>vCf=@Yitd{SK3W zjXD(|$J_seB)t>_17;Lgs&Gi#F)7gM^Xb+p48BGI_Fi8&I9(hYbC_jr%~f4p}d zdsSn~f^IrbqR4We7Pa*!`)d=}b3*&(1uw9+I~5IVhM`M&6D7YcvTNkDJazT}<$4w2 zYU(zY`6jEpdV@g3zs=T>f5U2a`h(po+^?9tT68%XYE7Ir&+=q zjV)?*oyoJf5R+WR3VpP?kgxVFYRnx5qL>%g^RntjQawV(OGmxb_8-+X2;Fq(WnYTO zfgA((XjOw{OJ@Z5NYghmJMTi=G#0I-Oa1F8OXdSPkY+lVE}^~c>TtiCE0T6{6oNOx zFKjPCdY?t4^dVL<5d4GF;F5EXB6A~XDU*b%IyhpE9fIgv(GjaN z0|Nt)AG-B4OrB&%FcR4=FDd{=QoNMtJa)>KI9j1yh-swtJz~vit4pu128W(s*E3tyhHFuqA z6?3K+8jJQUfG+O-qlC*9`3?qM<|T>UrgT2rQ0TKf?TR)gH@drfj3Zbz zLPoyb@lnfU1aF*grqZ6D_)J&7^8`5$>HU*i_0VbJuO3 z2*M!?+A~9$H7%^z_;PqVJ>9j8C-{5ilqWWONUPE|jG0@XAHZ+aORXxK#wMQSoBU+e zo#WmUe%xirRU@4fen)$Br?fa7tL+>oW`>ZO=`N3d>303L@R9vhS&L(SgYIy>4 zxA$HSPN{4Xa3r52(iQK{5gZl0zwYXbO_hwBUZK~2k<54IR-FNguuA3 z#{*86q(zX#Cp!FhidY=|J}@5fI&Ffa93^S0_(Ytj={pU)>_cjFZZTid4}N6b!+{=N zSaQv2Hr$PgF-g^)nIcLid_2<~s|A=$Cp~q%0iCE8^=l6XBQu(rmE%uTV_aOBa{9~W zu&+D~ec5#W=E4rNMk(G(LS*qVBk1MJ!9&@4G^o*u_&=rI^i25{als32mb(Dsj#@&5 z=}}9D*52>Bo6hloKih)z-k@;o5Udzj(+rElNI|Hco3)?x>044AZeOU6eegwjgyt`^ zAW-6Le06^KX_aE)C*4dpd6S6=^4t&E>`1qeH<&H#wfZN(oSqYTUgypObCJw%l+y2iOfx6+XMu_N2vO5gY-mjW;|h8%sgD^5}=tU+uuW-AUS zr+1`OFa(K+2K^AET>&Oi$Y?hGlC#Fw!|9_dpEN-)DNi?9c~M%XIwVZ; zL!wH$2~2s*nN=%FJO440(H~Jko1+$!=7{TvBGVXII00N)R4!o|TpJ&43%xL?#$AR! z=fo>7Te5%aN+W|`3YldAiQQu4wZ;t^p~CcstG9fv=NhT?(oGxuXFt2o31hFJ`Tt$= z_0mSGzOB#}5E})3_3zmjU&Fwls7g?zN>%@jsDVY!1xqCM##eEDi^yT_OWLQB;(N)n znZxE=;l*vpVcP_{#&cFV_Hv#8f5ekQtmuR$-Zp*#0pjbs8!j_$n%8w~G7`pUz~D8$ z73-QYOR7-9kO=P7ycV*-nXmq+F&&8JRkM-)XGTF2=iedod2MwA@|rNz7=Lo`I0tnN zEgUBuq@aJPn)}m1yv9W)K4RU54)4!Dyqrv|jtcIM-#Sb0KI!3Z2Y5h`2J}_2dpMf6 zu6znaE3ySdo0on3Agb+lQ^R*(E9;m|Q0avQG-QAy9S1KSOSrGpmsF+mzTIJYnR%Jd zbXas;PRUd1gv8vlsMgVN=T2AgbSqz%cP7a@W@Vy4h0%FFm`wy)9?7NHb}J z*vLB(Afzi9|H*d`EIJW_@`UMo)259el)K7%V#EeIEHGd1DGg0W6O0(Z=B*HZqvt<< z8Ktmue_13&%X1nU@_hF1@qYalDZa@9ISq>F(rXOF-8T$ zS8p|;=Y%G2jv%+bkh|K?*DCa$E{I|38IUk#SzDFwdS1i0Y2w|^__0Qo0%F_u(sV}N zeBFcgt=bpw0KXKRptf%Gaao_HWhy`UGgdAF2$n;SIH*NUU9zoi3=qAwohIW4Mje%@ zssY)=i??mlC~Br7X@6zpIk6+X>Zoh;0*_4N8Y@5N35~O|$vf$0O<^FqCbr%6Ua3jA z8{RvGO=XPdqo-5i(aGgM%zcyk_6kE4k0&mCF&2y(sAC3J<8JD=G(eLT47&^Vak!tR z8)I5jrRF#w!9xx*ccPqp85sk|RQ>hdJBnB+cpw%*q=3oM)l-i8reBw($5!H+< z@%6Y+gZ`0gh!8n;|K29Nmsu_bTxQ0c?I6vc%iX2l@+#*iG;rC|?VcBz6o$5-wceY0 zhj!!ED>LToIdO=Ge6a5`4^j(d23dV8vZW3Qs-etQ#)3B0CM5x)$S`14HHxp8 zbWN%IBn&5uA3U^t1chqr@$PHk>&pM}A8q9obMrpip!vrvDcVv>Ye`#Fw5E z9Tk1>>YM1*sh++Xquws7j?+$QwohhksuZ%@M~lNG^ESiu5YG&d0q>fChEc+lx@Hur zQQeEVXyn=oACv4KF@DaX8S4iaA?d-}2dJl1HZ5Pgm5&QfFaPhb*94VmSH82jbV4%w zbb5F47TPswr~9O%+FWqLS`B8o#$o=riEOP9`GkruqE>LDlnG(`(niyjr!Vp`4);>B znJ3zNbx&p9)7OT~`2p)5PTtz;)i_V@_hv98bMgQxclEcG>M5&{J$pG%jek_Z_!BGD zqIBJdt2rdDczuCMflT#ANUDmmH>e%y44D3vf%O4?DM`ep!=D>H^cd)(b@0LZj04^e zG-IL$C`p&Ir$=@QHaqF3CQPB`Urh`kiPd?=RhZ|D=0PhA*{rgGx@lhDywEEWO>b2n zuumZvQL~2+!H5&*w=8*=66)X0qxVwJI3WX3$B@pa6`G<$Dq<+Q0V~O}$y+inl^meS z<%P?AVg7EN%8Aa?W!GSmQ<^Q8s+_*z!S%WyYsJ8nn zN}grGIaDOGdJfw14UZl9?4No|zi;4%R7~QQ%pvJ5xxBc&%*kCZIrh9vu>j>Kr(#t~ zHGDu|n@(bn2HJgb$@7RNb70-4GXe+Gs)q@2nmM`}3@*>PzvU@U>T8;pc#N5L!)g2s zm*%W&pJb@)9_`gh1^$rllD1#U1bwA_z8#cAg zd%nruzLg>9x9r+-S;)Orqf}j9l)`LKT*W#5<-O|TOZ}Tb1P&bu;=gqzlN@1osM969RlbK z9|Hhs9aov4yoCl0b|*qHb#9ecz%C|g~>;?lvIMBGU~-a{GiJEL*VWihXm zD4MG1X`0ch3R+xi;k_s-RR5YC-83z+Ivd*w3Z~XMK9Ym4iVTy~2$?0?F7vKO$oa-y zL(ui^5c`E@$y;dzT}%M5S)B8`dxJl){rtQwm@^tLT;rhY6;8!R!?NjH;9!(j2-3j) zMH=x0#P@vI#~c`fW~#z?F4cp*uB>p02r_vw`9-PD%DpN>b@- z_rwVaPNwclp76>)Pn&Le@v`GsLH=IcrkCQ*0o6uUR+za5OpR^TLNrG|G&Hn{$StZx z8hK6Mxc7!i5Jx(57t-E!Z;^wbaaOv!RU78=-EVH1KVxaw)NMi8@5ry;(4PTkSs3lH zd(_;uEc~}5bo&@QK5uJVj5z~_^Jv|0W<630Yn*%cEYTu4hJ&ojm9B`cD_)^5 z08ty^7o%8e(uD_c?7d$R&3wWam=5xX9sL_TM`=HKid36*?8w&}_4>p=50YtQluo>6 z#m9=MEZ*P2ct0ehPy$<;R)sRn#+ID)5kPDsgS5gGWjp-#zx+9wXO+k`KcS)idhwF| zfah8*EtA_h27}2!l4@X3S>_Nx4ci+?ZksLjgcVtnA18{0bCLP5&2a8>0al*~%HBO= zz#B!=BA}^ef3fw;fD!CL*bh6IJ%q6G*;QuL8ox&5yf}m;tFN~-J^8VvNmXAYWUmKr zpY;&9wMq)=DD%|XzODw@GRH2e7u2GJ>KDj7xQfyl&Tv+|d2*ib)^eAmuwBSo8x>9K zmQFZd4Ad=A-{!OG&J1~*=IQHR8q7R`xAx**a1I#*H~BM(#Dl@Z^S->@0(5`#>4oW2J=I9xb3#PzULAV&REg3=^jKOWO)On&#;TE3{q ztwB{ck1#%79kBP-T{ExmCbBSQA%qmD-;J8MWy7d2$NQof=$J@lWb}tU!(|PC*^e50 z+_QV_8h%U{i&b&EYJO+-jaqqUUe^~>KcXmZGJ2;!jg~8PqQYy_%3Ley_w2bkrf(kJ z85khXhW3TzWbiglGYutx5C^f=im+!BwHQXz($C3a@swT4B(>s84!yU-{cE!zZza++ z_K26haLU$QKis8EQ!1Q9erPZ~Jl-|jom}BGmJWdJ$K|n=@^$kXE4cxrZ8;aL3F5^4 zW+vhCxSOFyt>UJ%jZTAb z7v<))0d`*E@tdw-OCuhdoWS^E164Xd7Zl-)^K20DY))a50&VVn^r)%?J$a6wZa|u_&ctC8 z{M|>_6C);vB`nUwGLaC=SN^*B2G-RH^4&uO&i8pp=Q3b3SpHO5-$6F{`#@m1?l z+bd|yWi^0%1(B^5 z0tRc!*7{Gf)3sJmJDc}%Hs7vS)g^mcT=@Oyv_b+NpX4}kYOkC| z-XIV6xs?u2|7Dop>^!>)q)9aARRCLX65yq%zl;SHE#GdaC>jmAK$W8}$nnTJ1&kUk z$j!BtRCPP;C-Bu?R&|?X2#u&&iQ5_EZFMWDb-^l6%*Bw^K!a7gU>fv0vfKsQTs_qN z?-j*(g64b!arTJUF&tQ2m1b{G`}5%8_wP;VC>~?_pW%FE#(o_HXtNSNe3(083t}7M zY6$1@N;VDR|Gt~Zb}hcx3MNw(e9*vFElCC_ z7P!ETL7Z8=qR3`u!J&4opbAezFL!Z8-Lp`w#}pI?Bu`+1^Flfs(K6_m@J+c6NXQTtq?{Tw5A zpj93qj^Aq+)evQZ9<{X!A5$Er8WQUb^}A}@Bv-j;a!ajzN0B#MQQ`%mj|nAox*o3h zb0k$>bZ}-OP`%=MV47I_-BhLD_Y#UvkBDdZOZ(mFnRu=1QPIZ%P)o7LoUfncX74qi zd+#OI1uxT1MPOKSP81xJL^!sWndgv{yy%wZe%^3Y$7w1oCaLdPq@$iu7r{UiwNzp; z*ABTGJBXZCvokrK?syO;imU@FRbJ=6Q;d`Q9bh&Tg);R6b_j)Aohwp*NWFTb#nJi! z7V3-hK};}HKOc`Zs~k8Dp`gDV%Yw4HO&NQ$9)nrWS}O>kG<=Ea^J3K8s(y1E#A zS#Xc@6Zljm;;^+2Y+P~NmtQlVU=A4ach_bh&)B^J^{CiY2YJr?!oq3>s=*Z?(OU2Q z*`b|yPR^awnMt_W3yqUv?5LtdqAl_crslhOgF<)|c`8`~L6<2=ptG)zP+2$G6m3EH z%FLu4v3davCt!ccLezgWuGADdbmhd-vIUCg?Q zSPT3zo)FNKfug)Og2y7KuipgZon%;Cy*hUn?d9v{Ie3&nfTjUOr*R|zLqi|@Lx;3!HKZqiJmKHmh4&#hrACKe`bh=t%^d!hfPGVJ z4>Zb#6TpKbZK_sfwz}WeHMI$J(ug- z_ew>`n{{ypC$oWq>S==rP(=mu5+M4U#RuVAHpQ}`6;8;f9zy{NRpi{`w+?;ar*6ZZ zq|6H$1eujloMG2oXz(9z4KDYtLG3sd`Yg_vz zn4+1j2PD!y44`TKVa=$8l0w^cn^^2h01XQLG=i*v%7<9D@tg2`@SDihfN(G4_~8l# z7C(mY8Fq`(qJBc{$4YShlhp00V(J^()pR2dFp^jQU}1ls?}XF+eJEpnU9maL#<#9N z$)@>5yZ(L$cdPN(Lz5OIuB>$!w4eGx>JhCX95#;Ffq=S~L=}Ei>E$&Te(>0f>R=2A z2%x$*_yYqD#h2Ky+<3row88*%e7La=tx1l$4@Hcq;0QIRnb+@C$pShyfKW7CV z&fv{PRzTCy2hRYzL@2IYsN4}JzIDw9NbikUiT#z0Ga^qZ$M zy}Zwp*RO#S1^=0zti zyK5|Xk0WFH6+K^2UNP^4kjMdF9mgb`QmDBN-jxb^)pmGYQIHegiwD?t*~8fZqM zfLvQ_Mgo{3BM%JOQO4v1Az~q?@f=)%*QdRp>KD#pUCL`RZphak<=&yFnbYX7?vuH9nxUkr3mc&j zClF0FIWuN>9H0BJW_wYIFaYAV?hyNL$`Ema9p&!rEfrp5r-AnCFb8M>!mla%x$aMk zbqPJ^X*eBylxfHuDo8lUJy7?0`8S(gI1BvgzG0*#knLy=;QS7!58H~HSht{xYN)oj z;pkjH!8>CWVT-Y%0?r?vwA?2(;f}Ji{C0Sbop9Y)EGa6>Fu3$29g57WYyvk~n}|NM z-D~#^SRc2CqyPEog+*cqE3Px-vb@S$hk*q)m14D{rUTdX37>7gvy3Ah^kK9pv!6># zy0Q_8EL%8FBZrj$dkfx^Cwvmc^9>faL}qjVxrtJgU|ZswB1q$97q|y*o>Ux$m&?nc zFKq8k0(%*~kXL*M<#-B~k}9x;^4EQFPp#+ip->{-5-9>vU!0l zl?6M}AUxcS^Dq3!PbbN)Y~p1_uNaAJxYp8Dh{l#ktMbZtuch6jJ41sq$O&t`{Jns_ z+za{svLdLL2cM|(gWU#%U2&`;g=T;QRI~L#)8b^(6RX{;$*-AvLQFZ(boh9kiPYNm zR8*Az0nze2+PIz64G`6Z|7n-XV>MNhZmq^joZf*dXSk8qdHLHD1}#_%wR@WxN|p-^ z(O%lTD?d@;1z+R(cO!iUhwA+nxLeNi!7B=jd+-0WaYYK@?6i%@0tVI)QT;8*vy{6$ zQbXkm=E0Vq^Y~H^J6zcbZR?TAwQj<$ClMCY-u3m>)I}TRv7BHAh)WHYTP9AByjG!) zfR7X^l@hE+aHBNGd(Q$mSSlHBksY8-fDuN2NVHr_xOzX2!BE4fMCsjub7vrGd5P37 z@wLL0;;W<#3wYgC@IU_B7WFKFXtlg7k;_7xfC$(3e}ud5t*2>_BSEWv?fqfHvV045 zyM79V(!cr(K+rG(&Fbwh9bGzbC{8mbvfa)V%e)>1hXBI(@xq! zls!myX^{=0Ntah+Zl%N0acJHii`>$d0{iIvY9obcGOHeQ^mfwZdi<~W_ZDrDf^}FE zBK1S`k&yust_B>axCw9wE)`ts!w0L>9Ul17fy#<2-F{n`!)d&S z#JO-A4W70w?GTB}VYqyRb81W5`;X33Ycz~m$l$*JuN0jm{o&fn{`i5@`s{hjP4Hk= zhWTs~8nEjldk4^7&S&q3x4m27L55|tV7&6yX`CF__e6Q9gCFm%FBPjOl!C6*tJEcd zhXsZl@XCP#$yA5ZGrQi&oM{~77Pw>m$FIP?5OokJU376bV7$k>h%xEV?E}&~JUDjXolSu=iA~Ea5N55Hi4%9>khrB5q?H#_Aw7<=KdC$5-vKX!}pKC_QyhG{Rz(2RQUD zHX0Q93>{w6poR-(InCbeNqk7tl49q7DD&Kfh@NO{wl;VLGY2H@J!CyA#wG0QP|k(K z?t26T&V8D#!C(k9oLAtT7^;F(96Cw+u$Rt;951^)@bA8KRP$woeW^+FFUG^y9UmCj z+H!eL2yRa7rez{{^4;Lc&8FupRd)jA7H*nClWBtZ12m$JK1@FM#4Z^9Ai+C$&0tp> zkJM|^gFV|fl~E4aw6hf+15LPdpKRxkolQs7;51-Axl*WfnG>Ka;OtLz-Q`0vQ2#xk z@>$3(ifxJN_)eDpVnf(5gB39-1PSL`UpN86J6IY2GvwdR^`CC~gUGYPK;{fqZP5M* zAunp?9V=e*5vYMdV7g#8H`*`^?uE>Lw~S6e(sHydG3POj-d%PCy&FI_UH<&AXfR}@ zbU}U1*&j; z474Obf!QlNM>*p;oVe&Fa=%ypbzjWAQ+)aKrKG&YmT`yLehr)hRd8ZD8)HWX1Rw@&;`S6KDVAR&WEw2 z^n%+x1q&`tME!?cUnN{$%9E)XZ22du3FYNT!&f@C02R_)!{|HvP^jLX^dETWB0j=5 z30+BxXeg*h(BA~*{w;W-Rfb>?9}FHAS*Zj%j%qHB(D{*4fjcJ#>v#9|8X#lX^GZj| zcmRi3J#hdGMppMxULu>YPWre=O**JSkWse3Pu{U^BZ?AvUB@wx*i3avx`v!7MBwKx z=O}k4td!bRhg~L4$zU2g z>|y8+F0LRlUaB>xKDR>tBQAkC{EIR?y-WAzmFfJwGFg6Aa8;QI*1gFQn9fC@Aq&8X z+fE0$4a1~jGC(8iBo*2OAvFZq)ZYe`da>tM@v0=&6Z(~fzeHo}Y=!Eligl_8@Nhr_ zKx2!J^oHrhoX6OV2X1c?Kv5IaE^*+c6ftn_xqtM*>~a#MMlq+fs!YJ7$>|7H$x$X; zwZnC93h`J~dg@Q+m^nB-nHBtEIBX)=&a4IRB?-%L!qH^9RwD)ya0)tXx46H)aEx1e zzlgC>9jq{1VnUVEL1+Qb%BKo5Pe^!IQ!nE{GvVyg6*bTnlCV2^lI@iyBI?y#1aIq| z_y&OcidUyAwpB$MHHtv!P{Cv^b}ti4NylEO#E-9H0yU5pll<9SX zoRv$W=B2>4{;$0+kB55y{vTJ-LK;nxEY}P%%9bT#ug1P*$(AiDipWxAzbOqe8f)2i z*-419RJO>TrA5jvB>T?qymfE4&-eHHJs!V*e~-tJ{j|jt4fnHjZw=OJ) zUn&zB{${F*vPPWAqagEbokzG|C;1&$_+%29dkj7{Hh)=e!-;s2Q(qbLQ)aHgRwxG8 zD3f24Nl*yC@Z{RZ_Jn5i=r-t?JMnJ??m6~1S^xT~=b=R`SEl`+d_;hbKG>~{0bLL3i{B=@sR zWL^}}sQwyWFN#kpnWXG!+l}B=ZdEp9yxVN)?(K_UgMBK!?lhv?e~|f#nSiNSa_Iyg$1pbNF!{0^!ZtpP11R`w(F9|8g!K zSD`?IFwiijy{iEz{8jts+azwsB%j+1*)h|O9rizS4_d4Uj$ttUGpwZ%=Wv8pAnedN_w>i*yqoxEnL+(cq`vcS2R2^%*gknRIePQ8qkM~rMI6R=cH#_8o9~c2O39l$`ZTInnlNC`qr{>K+FLK@H z=3+i>Kvy+38hD?$y_{?H6fiQ12Fy_{-0*~gMM2l+0~CP{sxi^+Lm!2}P4x((>-^8$ zil(rxv~2H;fVp|juqbRxL}68oTa(*#1mgiF>@_i#@i@L5<_3H+U2Nlc68vH6T6}Vb z*ty8&gs8tx>HQ;Ab~_LqKZHc1V8>JD`e{u4NA2N(cre{z^E<4?0w2Md_?FMnNwbg^ zOfA@VwvE1)cNY+;{qvHMw`<@tRRj@U4#3mQ2e{~h?$Fy!qXLyUx_O_|b`)qM(Eh>( zVnn^t&u>mi@;JHQWsI#!3sQrL&DRtDKuMfIR-GwnR%O-T3AOCNF`>?>=1~`R0_OPY zX+P(b?twO5q3cjAXskeX>j#uwQhWL3aRm?7G?lvG!bz>&zQql1zR(xkQdBH~jeT?B zUod%)?2iG(DB%oJRmFie*B=OvwFHPuH+($3I!IbS$F126&vW~75g{JChzH;idWd|( z=<(5kGf)tye5xutq;wH_TEbD{Ag>l0IyWH-kja$P6Hu+{RC5Gr$N>b8EgX)O*7IUP z9w>1QCT!d{Sufdt?0O(Cbz)ZFNu`=Y+jI>Bx(X_U69O5KiDs-~V+Q}oQ?(*e$)=(S zK}HJ^7!{w1Y;x-qxE_S)`|3yMAI7YN!G>tnoCf?N0GsLGm|S=os(U)>e|o|z?%Q7y z16BRF4L=J1$8{S)ubjnrk_%iFyN z?u1|ZwvB)4=2{HCW16?{59Aw4u1T#H5e7VeeFMHe2xjb`yqnnnuKv6Sr0P{seHMrS zK=`V6$1f7+%-Y}Bo>f4LFp`jz2UJDr1$5@f&35hGsoQVK5d;QDCrv!g7S-E>yILff|C>I2!U0@?| zbRY@OZ5Z>9f$iJE8@OJT2GLc`iJy48yuJhSizngd2;p;nO(R`EhhV()&RW+aC>Y*f z*V!M4y6z6Q3UiKho=Jk;ZK;V&5&vkqJ|r9(#`>O`EFWcXtZM;VkQb$iM!L_(?r&E@ z;HwM*&XvKZ>_Y*>zw_GPC#l|xILw&F0d5lDI&;pAXAoi2ui%01E938im&gOu28=2<{z){Y5EO1b_{ly8d>Yi0!wDYUuTo&1aY3fg}G zyJqVH-U?*-`}re-&F66)3|Uktx=7RiVIa=l0;}WO=j-}_j@>@n5rgbbMvfD{9O;2v50l(QjH5uAEbGIw6 zlRlv1JS@M0CPExX2uw4gMlp+?D%QDH}$Li$^8~Z+ao9ic{USB^GC&eo3}Xy z3EsLaB+p5rB~ohbf4*nzweyPbdiNp>BYwke3mjW?+{Oe6Z0>9;<#01?7<>|*vRI}( zV**=aPKmNWB)R()K6!%jw+u9&fNdra+2c?{8)Q@2LBO_}rKcUSRbXX>Egv>L{EPa~ zPqB7R&&b%a4xG1D&H3;fa~4o^QeCqO(#`yd@83mPz~(+>g;fip$)B_+p~si4tvU9| zN$xH<3M`{kqUDxnFQ+Ux$tQ z`j}IMEE>%(<{SU)uqrA?fh?N!M?w3qHuJHD+kZnt3li(1bmsFF9z|jq8Yc?g!^}EC z{-yvaT&*>3ppn#zMz;pV=D|?0M#|P)htu1`tUG-x!?%^uHIOciOSfj^3|q#l6l`-wpC$#+cw3D6x2Yt8GC7t1zbh3!^E zqGa=mtzQL8fu;*Ex*8J?YnFT|yD^tP4tv@~*{yE;M zQa4a6M|~T-w`@wSWwU7d+O8JgWHrsQH_Yp3RjM&!TTWPC{4zF&9d1AuK4op^ zI>>md4S80upsIx$%gW_+7zglzxgn)_Mh|1k)b?1^PFVAAsb+N?RLwcJN{c&R(svjw zcL=TK-iAj8PG)?Flp~X}yHM_w0{{o#B|0AOl4nv^kym1>*p4hD79AS7yve1oUd(8F z0;Wq4V3AKI#xC{Aai~1$rU&C>D+=OjafkPrzImg?dhMz74hEEIWJkxeLd_Y~oRqjQ z;`n;G=RjdSviSLl01e7t|6EYjxv$SElv<|CkZn8QZPnnDt^fQ-UFsrpRm$OB^5`|!Oe9BiXmWi5uDoqlF5I(z%* z@Y&}_f&-YAi@UydZ!BVNg~kC2I!m;2?SjpZP5=WU(c}C{oL&ZsCe{tDN}`+qmeA~`H23wQ8@Wh0k!J<_58NGD=z<2 z!;3xNR!x72z;MVdACDz=uBBVu0h_m;TXW_62{Jwr>+9?kHzr)XW)W4)@RF13Xxhad zJ(G0JoS{2=cE{@Wo+C)?*eqO{?5-<#9$ryUpnBR6ALI9iGge_{_wIF`2Knr}8#@;>h-pg((fNo8RD3gfV+9$&;%+U(nU7;E`@O#+4L z4;?Jn-}2;p!-`Cl`6jg#H`R+T{O>UB!MsBxx!CylO`c?n3e~i4xs-tvv}2*DZ<5bJlbaXIf;g!T7U z%5(^6f@a-hYbK0GYufc4!C&mNTf&@%3fo@q^^LjCBgz2UCD z%epv4qVQ@z#_d!($-Z}~*!io~u+uF?OpbP;UfyI-_>$6{m&Vm?Z9uLts9vC(5}D-I z)$Ph227}1!?&-}ayrlx;=p`+3%V^W^BR?rueb)d#=ST7J-i~F`&O$mcD%-<jR@;aE^LJcOLajz94v}P8|7cR%wIE+hMa1_ zeWNgTc3N%VMH*{i>DjmDd>RIeS{fRrM;R;6r|CQrfd6!Lx*)DJ8B7v&;c;)A7Kpg) z-O;|o*Bk$d4b4WdEAVgUYMk44$U9sv`82LDU15}!Mj zZnOvRGqKW5znqi4eED+Y-GNB0bW%(z6&;Qq%GU7rshEukk`55@lXu8b$4m#3!a{o= z@KZW-GlfwGAHB=;6bM>wPFKBGSmEgr&br_< zr(wBWx2N?p^}H_@voR>u6nCKfBWR%5@~8r;`iH6@ERB?RV2w#|7-^UeB^uZ@QKn@p zK6&TpWqoErD^TtyIF)%`C^MYk zoS9+>Rim>_J{B2VzN1!wHa+V^vS*c|_U{sSAp@3EJI)`pV+|KxyI0ShP z9TbRK+M@&)8la~bOL2%|pb~d`t)ybu7?frktlLOsv?|odMbV(DR}Qt03v=eY`He6# zN!l*=CqI?VDd-MAk_%9F2%`~F6@?UXh4nKBI0&lM77BLthUOi(+C}h6ll+*uDAmr0 z4P9N`sDO)Z5vn3R5se~CqYX<+H8%zqSDb4urb-xQ>ZQlg!!pl$d-8mY<(Sh2_VIm! z`^c5Qq<)zKQ33j{)}y36LUwGw^UAPXfi7e9`-j*Mx-1GxpWePzFFX5b_6^1{Ow?C9 zrG2kq5ezBOf}k1%0HQ7zEmlI;0+;($wILTaiarHw?-^d z-MFxI8+%87F>MPSYAmk%-6fbCA0I{Rx#f7BxII?xO8FDH;dNTHV-gWUEk{SmH0{M7 zflhsbG+%B_XQq=Dqc`HwdcRAOA_XL=nam+4%efPdWI_LdkmgEC5-b^Ra7{3?m(%Av z=MYRvR;DrdAIQTML1LX5e02ad-;s3Y7-er2jubyL@MRZ1s!4=LGj`Jf0HOoj zHnOHLd)TY;e_+v09X)kqO$L{&7rkOVrMap{)Jvnf&j&~o&0jU9z=G7N_=;WO@BXfa zPKY4(>tTmCKqE1|5Iy0R)%C3;(`oqoO&+g>w`wDc?<>{FZcUGlTB8z`bd0?mh^%2m zEqIUy4UKd5Z?)H_DFgMX5W$oQOFHG!XZ!3wNo26-;SV&f5YzD##R^2y2sHx7*e)_? z#zn(eZRi|PEYodp4NRKO(l2PGvKmA{-Z&}%a6wDJmpy~eAX&oSd<(zCOCt%7Om z-*v8x@NiS1D%x#CHNpNl7Et+P;LHBVlwqd@vYs+@KTZw6d*r!SY+fYK7jSi7QgXDs zQN`?AB&^_~+dF-j5p5?53BT|^ClCbqs!H~Dq20SmX|Mm^bXcf z8C=!4Avs=&jOIDG6XjxFVdir}$2);*wI#@pItz>drH=$J0qkZO;H`UhvEka;R0(Xr zg37-zH(iz=jV*saxny@jBMH@?4B%FD(?iFP7om_q^W~|tfzPxUWBv~^^!->=wukEQ zBPE>{THHV_Bf*C-P_Gp)U183MyhuaUL3C4SFUE|O^-qBKlcQtnH->t-q28&|MyX~x z@Iv2B43GX))F0ND0>|VF`a6}PPgAvM`axCKfdkoL5LAI;lkBq%TPGB9lr1~2{SVD! zXOb!EY?>+U>_hhSA)4?|zBi!d_;;elf@^DQ-G`P1LwohiSU<7WI+d-?B22Aw>$3Py z`89+>E$iX=p8gnYd}K{|pGkE5%5%`=Tkl+MOq1E91Q>VkzYsX)TiD-;L(ZguoN4N2 z`*!i2MIR^&E5dMpR893`V&fV>zM#5>!BXJhI+%-9&rIS8%y{;IUFNel&y$TfQSyQZ zzmrL1R`EC{T8|v#T>BVAFK8HiHs@<)ZKgm;MIRbjanXyZhp&!zexb!U%2}TW3Lc>Q z4gc1c0D$pL5CJ8IUii8bG9B|=M?K!d06Kb`(m%hQEpIVZEDRU>rv^A zA`oIYsbck~=V0y2dpfDlS%N=MV+lTp*>-S`qi*W@hlsBAKujU`tEEp+wQ4mGht77` zyOw>#@f30L4P}WK$g9!H8qmNn#4BCkzB-h^CUtZ28Og%)>9+-1P(HM{TKW!V-bIh@ zD_67JHInvwI1g2zSH2x*^c539_|CF^`-#7-|6_2}$ba|`F_d_IwYdCRmPy(3J{9xe zSbKUw!38~@fgl}70yf@VpH3CJuKrL$?suA(oPF!Vgx{)%nkw}98fuh~VRUo-D5P7I zlVkQucPd<%bf8Wr?K`n1BUqh!*7y65KlT<}jyvsf;?cGk#%Am-QUB!SUbYbg+tyg9 zd4@?9cP%T+L`}<1mKtoG%kGloizFdNP)*I$vFsHx=1L54H8o6Q|4v`_^I1J|=lRa{ zv*Wikne!!V;B5Dh3zV^W=(yMi#+t?89Mm+eP`*j+<24THrzazbS`P*GST$hUlPs!6 z>B3G^mA67E!PH5*?V>hcdZZD`IB;hk2owL}DNm1eRb ztBy`0Jk8ffjv;F!ZoB5BD#wA&?qF6vs_q$s*a%?K2!i3Xl%F%A-?(BiH1B-%2Fmwq z=T&}B0VQ`sMlXwU-utb?_JLys%uO5Ya1?j7BBp}HyJxT~!ZM z+7w3EMKdWelNAoq`vCgcbt#=yl$y5RMUuRFkN-D_I_*4m`Hk?g9jLK?i z4i!hP8v_kP7g=FBkQV*xdzoO>>l+M4#}0l|j> zZ4LD2j_K4y{-_m2RZAd1PMtrvQ)#4e7Wf6 zW0T6+gM<&Tz9x(DaQv!t0{(iHcFHJ8`R;UPCv6(#siA;D{xos47~i3Revg4t4&h>_ z)rsl8C&L`;6ReH_qIGJ4F7CzW>GD=vAYP#nr|VG+V!6tne0s=sDWXXPa=3yKb;|F@`Uap~Ivtntp~ zt=Fx~mq%7_N^KrjS}!0fW^4B6D0cjBLY#i*57hk%D}Zpph|8au@ZRTG29?5i=QWFr zwq84xeF!8mC=3O0op2IQ+djmX4&)Fmx3Hmt<8TV!yax2Rxx&zzg0Ox|m;7omNtB5) ziW@E)!?0`NThrb>N{Ee(J#S_fKbAWG8htkk)#ffMML9B4SUh!0i>t<33BMWh9pgIES5i2&ojAibM;kC zO^u7xo|9>sFK%8EtCChwzmpE7W0h(xwXSLJywAI3+&Z47>cgmB`5Y9UPmUm=z(+AXzaJ9rt^oupowqC>=9Z%p6**L zSfd-Q_YN`7A%^>o9sD(ikZ>O<>>fr=tUz!Bth?S+85*rjeT$|zb7~5f;*ys?MMcg8 z)0};N4mW0lfGv&q)%t@5;pFDlliXAV6!!{8JD4JW5)*hH`Y`h;yZT2I(Bs_bnb1-1 zwNdpe@s%@H{8PM`QX-E}y~8uaXDc>#b_P!=VTe@G!K_7?l>(=FuR7!1@8?ZSJfG>6 z4-8WzeGZ||1^h4-fLLPdx~ue3vz`0*DjjkzQ>?6W0*yu6t?@mEYglP(4G48W`My|8 zu@_|yMl2h#$PYa{_($Jalja^(DvQC$$Vg4Q{+lxz_yIpK)=}+O^=mdF53J{lMfQvcI=lEs8SD53mHQ_(lG~)9KRcNJz_>rjO_>EH%DlA!8e0&yC^KrRN)-#_k)#P#FA22JGAStsvr z`2H!PdWcRlVuOsJ5-K7n|bul-g2ZdnYTto#RZ%3#I8iz(Vw1ng%XxH}u%y|`b< zsr%18)eoAcA^%iuao#xE=S79&Odv@Dj;aJq-odXcmG>-}i^li6?Z1i*u2ue+4PAAN z1}hqHI)A#fx$c$VNW&7;q$~O%UYOH~QyAZ`5!mRDC8kaZT#$0_KcFRD`RLUtG@_lcEvqDc2(D%X~c=R^z1Jn0O=LkRO z@f~d7u{`HWJQ>}*%AuH=Kvu2??s=_(Sh$2sU<=Y^gzsaQk?a9$5-R_56*pQM#;4Hj zXxjUEnl(&m@&+7#ZqwBdD9)=ea}cc^24^pr5vv=V!w+b2y(~Q++{?dnEKV_HQC`3W zs!9=#`GIiEp#KFp{%d?j%kxyox_?3dyJ4jnU3i?HSvwA{)+b&(7|hfK-wc+U>~XeG(sZw=Mn_RK+ zR3>JsC$)hC$VDf+%}lItDX(_Wa0I1I8I=Oh=a zhau0Qp^5n(NUDn3airjr*`r=T8o1O#&4CcX8y}kT?_&}k(9m%-C(XZkM?RLv8$e~D z`!pu_!!^RJ3=(E*sc#(5uR5t9&A%IK>5-=MUVvuo`OTujA3=+*D2swCki`%{zNP=& z)#3~tC?VN##Lb>f+TA6Odl!69$R0Q}v*2jV__ z+3Kr?ciAETixugFaM_=15R?5rXQ~Om;YrLv2xFKK;A*S}L_yftVor2hYn<1q2^58E z*uXW8--8mR{@nCfZw0y>gTQ5V2uBbhOKnvv*z-!rQmraJ|7YkZNx^-F%RH$f%Doyg`7|VVHk{+u-8(PA)z_&nr zK>SRK@X|2r$`aR0V;AgnLR_2}{~naZTYmnQt>H&eEm5gdF>21d)L$O#LeZ!d8A*jF zk8z(yc1KmuCvEg-Yjc69)g~abobLP>!b+CwRZwFK&r6J@F2k%L|b%^*i zkF@Nea(!RZR7AjrB?|TDKv`6a)YPj5^vt;{)gx`JjlQt~W++-_`QW-wQ9OZ;UV+OA z9WbH<J z53sU8CFEv7Y3>_Ets4>-R|7@sTCdjFAzTyn)?R+=KGVHp`cLZ!b6)#Ko({3jGw=YjM*CpV+=D=h0S!N@XP@4J3k+U=tp7-ktS60%wQ2vQ#zsx6heg$dXofFgm?-1vj zRbaCBx__`(NLS9s2)wV`9R06Hv>jHKwm9CFlJWm8t6ZazV zA91Y=zr&vQ7M{~>UjWL&?b`zQg~90YiVl=}X4pWO4stW|8*u=8$q!MMWou19N+U3YBuh&q4I6>-!}R2K;DtD#|Y( zz#}NOe?UeHHO2sc{KK#)euu?=Q5I+O7K)D+qvx4lmPUb*P7Q4u>hauTfZcoEHG+40Rc6nXVF$esR@6CYkQA6hkk-|Ed zzwWfUL(~5CBKa(_uN40w+8A zHbq{AUYmE{37*S8#)~%2uwYw~ zOg+!;>ZV2AuS`q(K(S1kdje%gqKaBq>(A*h8yDs3PX$>r%OSzPqX1gu<=djn&Wo<% z#kh1ij-oM8F&iEQT@4mGP9`x{qw@sQFzt0_SZ z6tVgHg}+--b-^2PdH*ng8ECwP@S|YgP-pzD@B(J6bm&ojMlqt~G}1@ugbNZfGc|*1 zaf`FZa)rO#k_pFFl!GOG3ouchm3q@e;BxDb-uep>Dhn4-(`WF3`LNAElsPI?K*6)% zY=gl`rioHEu#=5jw~G8pMd9~^D>ZbxX}f8keZFAA_Ag`<3#*1K=rNbmw2!=NG2cTk zi;lZZ(=KfAn?ofez+Ju(XYc>$bNSP!^LMzucpw(d8DNYDIs%(l!Tzsd=ARW!KnYcY zQVvNa6|Pw}%{|gC{13b4ON^-hzNFEkxxq{C9&`mPAF=+U`f+aAXr;9F{R!Qgt#BIL z_rDs(eXfWeGBeNbVMv(vA^DT0->J-~Oi)iyU$1y=*a$cYfoH_rum7P&Fc)+2nTOO< z_OaR?rJ4a|j`H{V*^3I+Lce=$im`BX*)80@l0&xMHmG<+y~qF_QkP#4`l+0S*^#5O zet=9DcdIqCCgQk)a~1>fCt%%9X35FcG2wWYP#jDB$Y@N=<^_j4?%H zZa3<6a=Q(y8oKnB?Ca|ZpAjbrGtPrMZN)mMXf;M+p3CIC7fW?N2s7R?voYzIFJd7L z%R^vn->?jNX0*eC7@0Un>Acold)Md1Q^PeI!A*J=tR;fCMcapdzCbmY+itjgJ>!A- zK}y1dy8Q;wB1y>cS?d@WfbDOk!NY$|U_?o?GEvUi+EpS>TNU~=d6x=gb&YxNjnRj<9^R;Y-DCqF-bTyN#nKuq0A8nfc7 zLbxoGsoP5i@t9)j_QBoz2S4qnSlS)I;@ zdq07cliz6c*Sbc#>~m|$A`gqM0G>q$Eqv6E)Y1gtzsuovVY#<|9LiS;iVcfsS<&dJ zQpK9k)sP|tArW>;{Kw!txbA0RlukL43cvIA^#&#X~h(DFNd`l@oIQv{( zuF5>|duop$9RB)z{m0*(6(N_U!iJ?-+2REbah76_eH^l$<>p%hj@L+IsmrOIEePgx z)Bb3Vw#mGiYB=`dI98TB_^YsLzm|B$&CL#pI3L23I`)?F-a@N-;KaYc-j~!>I2fqrUnc1p8&RFoyy3U8Tnl;R)ITR&WC&rPecS(DPA~7w=P{pG-EkUKU;j_v9sc@U<{4v|Av?%F*C0BFP$Mi zUrrQh`}^=y{|TA9@)oq$yl2~zBDx|5CUBOH$M;!eB%TVtA$%_U)McNHgko>^iH?qr z11;qafD5vr*t&tTZyQe+kf+axUdca=qN(};3u1KL)rhg7{>TMVWDn;C6I!j8M?L@r zUIPUlUg?@(Ah&zJaT;>@h*)j7)`#7^xPGo^?DuQ@0-q*q=K7kt^i#a8zo&AjQoQ+f zSkb%vZo)ops_tW}r1B^9CO>N!fZA(0!xwV{UOJi}Zarl@yWBcZ5^22bNjc&W+ZkG!M(sUL+D6gDB zYeyhA759-j_nDHi3=`qzJ^i&STCxQ}O3`RQEtChZLb;mm*fPt)Mkv%frnm4z-4cV; zhsPROuFxYG@=dIh+STmJ{eVH@Tq$qvahZ;4nK-+6hm~2*tfaU13BE}% zdvyG><%#{CN~R~SY9#YG@l-tR%4(J@ZSkH$-vtw+a7df+m86l(73Bnj$Be+ZZt zj67t^!R9aLiaCOvw2qs2*t*^|<{fg?3uT2sMH{T8dfPfgVlX`Ucy!e}KR$0VB-Z|1 z$jkDcCq0Tu&bC7k)|^#fOq!USwrkPlO6!SXFHirlU_tK9-W)9Zc(7VhFT3j+WZMc! z%idSov{;DmeFGrJ+n=uNN;>VxfQq?hcydHK;j3q!Yj85%j^HHeg!T`DDl65CYJz26 z5H)+B1UUv3#cg4`+dc5Ag3NcM@LErvT1mWFMcD%>59ib)xMbdg?$_(1Lrj)~p_U{8 zmn>q^8r*(`RFH7P9w&7UgsyJDy;L?a>ZU%QAiv9i7OrEs<813fb8(MmWVF`00&zXn zovyo8Puu+m*ORIuqYw(o4}yM2V*P9h-NS5zz!2C;vcM62&3Df2fnI+kZaQqo1_bef zp?0W}F#JLeEAU{O6&+;6JQ3I{XrC}k;f*Z9rcje0X9h^$Oy2iPDJjT*N zb7d4;FAz|miEPXiOep_yG9C;^k(3{Q`IjMAk(~Cyo!F+zC_MeRAQ&27hJ@TV41(_@Zt*GWR6p( zFAUc&xO)|>;Jy=LVmFLb!)TSpqOL359$@#KIj>8v4Ls#-R5m(KV*RB`#Z zUwY!!gy3Kb?L^iY|H5sR6jfqeY^3x+77uxpsNu26U}d=@Xer{WITiFVC?+sBjd!bW zPjSxGUh{n2yZ?gR#5 HenJF!fYaw-MX!{;y3*;Gc*WgE?q5OjSQcw_$EAdneAd0 zueG(0bt1X;rJ~iT<{Rvd!?R@8S|yxi#;SwoK?PK7$Y6bftW$e!)C*B;UMS7RkI5Np z>E-d3GMLWBli;=Zh{3a8fyqIGVkUq_z`#F9V70O;koN@?^|sC(u4|eZ)ar?s*?ha@ zJfje`sUUqax77tJK9GbN-?9-uwAls!p94)hYWsLmO-HuC>m#?rL!ZgQ3^szHE8A>a zEt9D`pa0lBKBvHs#?3qB3{}mwo{NaBC1^~&SdYs-7N>+A?jpIIBdQ19bLfj7s65TN zl^@fE?Bp4y$;FBgLR~p}wd-VUvT&)YFlGrQfVMv%vh?$y8>|W$yi8tLOaf z+^}hZLnUt&0-cEA+#;Vt*w>_}hYbP4KOBH!tLswejH zNi7nW@eynZDy7}xCO&kvT=NM$SW*}<1 zf&q5MYW|V#w%#b7kbjcU6|rj3yU3Hn@MS%JDF!>c$*Rqgb&s+@MQt(K@-6Q)r+r}f zl46aPGEt^Sx5rG%=&I}tlkl?x{qL;kWONnkqc`GCW$)B+_ky6}sf-c-9*;9cRVkYj zZ^{o_P}e$^!5X;=2sS@O@eUOdusLhr1gyoMdsy=-4oQk&ybvJT3?xzQ~3?$&5IM%G~~k@LfX2n=)hepoGne7Ds5V?L9=V=YYyR)3Rbibq)*$ORIqg|5 zX_2_-x~=Mu^YhDmm7aPN?eJyM^_@=zgsR8t{01Ri$~}ZM>>Pz^=J#-@R2DX-mxA-%KGc+Pu9`93$%1hR1vmK{-Hbt?fm6@%3 z*Nir@4$@d7_j0#v%Lp*$5OOIFMefA_B~$&OPM_C3lHBhLTf(ghs^&b!ni40cQ|DJ9 z_TooYcA#(EB(LVzr$z7b6?h1xMeEmwmN~WChWU^y*#iKMgwJh~p2@SA!irT{M#>^M z3ioQLQ2j915a=3UTDJfB9>UGxckd4prUEg~-DUo&8972%0wa`WRe3gufe<}K5`Ik)s=Xj<#_+FyFq^!5{c;Pu_7dgeCR z7RvUr}lBgOjax5i^By|*rLjwUps^Y4?6 zr9H_U(W&8)!f%{=x;MeDcw~h5s$k;%e@L&6f3vQD!E__Vhk$KX7+O)jB_MgXUboR1 ze`AD)hi7zTWTf&azV~5VL?c6n=2s2;>=B%$A~t4Z6LVMUNbpJ(y4D|~+9|Td>GXo& z#7YilS3}=({w^IEG}BA0`3fbrsSg!za3s$*>Zw;9BA8wALZ#gF(lXWORo}EHyA>}` z&CeQVo9Xh_d}aj;)I_E+K@3{e`?dB(GOfxIbSkxEXhtt*LEUFnCmTFt`O&FnMA-Wm z#O0YH8Y8^WdnF^V@s0ROc7motjV^vx7d<{f7}li>X0pyKoW^JlvuX{WmOh9!7mQu8b|&YK zvSu9RnbReVW&msSC?F-cE`CeuJ#RPn$ul6t&H7zgEHwVB-b9&+-c#cqtbwun?D~w| zwqVf|-Vq8-zH|zGFl9Ln6Hy}nCpeJy-do4rdJj|lQOr`f#rVt(HeXJN6pWQawQ33L ztpqN^g!S==Hv{}gOzepT^Zl888U74X2r<2KD z@;{q2#8nVFg3;QOGgTc;-tgy9rro1U>QmFG`@)bRT{88Ux|q3Avqd8LaM5)yiv9-p zuPl1+x9hd*2R`+=l=dTm44yS%7V`Ab>FIedP6Wmo9CF%(w`(Llb9#$=1jXZ%sq+Ty z0-jGGuH_sFRR&tn8+0@o$qc8H+*HVJ;@EgHDU32e9CDM4U#p6{AD*C5_QQo3J4qZR zHr~z9{U&{cqN$i_Efu#u@4V&lwI-53F2gp;bkUAg@+tNb9Nzcy%Hoo`ZcRvUKULyK z2&c$s3m7a7H7u#!b~gMQory1bncFjy?_o2j&zBzhEO+vxYoj*QB>eN^`w}uXV~;xK z-`3;mgG0aAIT2GHN|qiM5Rg4iuh_gx{h+BPnj=7Y4WjU8$h`l24E{;S4o}t^-ege8 zmAr8mk?Bg^&H2WQGm%J=z)0wuzg^at4%yQ|c-U1?K%kpV=C=q_^`*2F_*}91SW(3W6Jf>Hqz4 zN0O%F&>PcufSaS&dz>2?TQ0B{vh4r;`R@z-_XYm@0{?x1|GvO~U*Nwl@c;S(-_~EQ q!0+~{5Krm-zkcTbtKX}8y~Xe&Akz3_`*jj>a1{kjV%}+s+y4vt9N>Kb literal 0 HcmV?d00001 diff --git a/apps/website/public/images/tenantial-wave-mesh.svg b/apps/website/public/images/tenantial-wave-mesh.svg new file mode 100644 index 00000000..0a7dbc81 --- /dev/null +++ b/apps/website/public/images/tenantial-wave-mesh.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/website/src/components/content/DashboardPreview.astro b/apps/website/src/components/content/DashboardPreview.astro new file mode 100644 index 00000000..f1b0cbd2 --- /dev/null +++ b/apps/website/src/components/content/DashboardPreview.astro @@ -0,0 +1,1092 @@ +--- +import TenantialLogo from '@/components/content/TenantialLogo.astro'; +import { Icon } from 'astro-icon/components'; + +const navigationItems = [ + { icon: 'lucide:layout-dashboard', label: 'Overview' }, + { icon: 'lucide:circle-alert', label: 'Findings' }, + { icon: 'lucide:file-check-2', label: 'Evidence' }, + { icon: 'lucide:git-branch', label: 'Drift' }, + { icon: 'lucide:archive-restore', label: 'Backups' }, + { icon: 'lucide:clipboard-check', label: 'Reviews' }, + { icon: 'lucide:history', label: 'Audit Trail' }, + { icon: 'lucide:building-2', label: 'Tenants' }, + { icon: 'lucide:settings', label: 'Settings' }, +]; + +const metrics = [ + { + label: 'Overall posture', + value: '92%', + status: 'Healthy', + meta: 'Demo baseline', + tone: 'healthy', + }, + { + label: 'Findings', + value: '14', + status: 'Review required', + meta: 'Total', + tone: 'warning', + }, + { + label: 'Drift detected', + value: '7', + status: 'Critical', + meta: 'Resources', + tone: 'critical', + }, + { + label: 'Evidence items', + value: '1,248', + status: 'Review-ready', + meta: 'Collected', + tone: 'evidence', + }, + { + label: 'Backup status', + value: '98%', + status: 'Healthy', + meta: 'Successful', + tone: 'healthy', + }, +]; + +const findings = [ + ['Critical', 'Guest user admin roles', 'Privileged Access', 'Sample signal'], + ['High', 'CA policy not enforced', 'Identity', 'Sample signal'], + ['Medium', 'SharePoint public sharing', 'Data Protection', 'Sample signal'], + ['Low', 'Legacy auth enabled', 'Security Baseline', 'Sample signal'], + ['Low', 'Mailbox auditing off', 'Audit & Compliance', 'Sample signal'], +]; + +const timeline = [ + ['Policy "Require MFA for admins" changed', 'Critical sample drift', 'Sample step 1', 'critical'], + ['New guest user added to Global Admins', 'High sample drift', 'Sample step 2', 'warning'], + ['Conditional Access policy updated', 'Medium sample drift', 'Sample step 3', 'warning'], + ['SharePoint public sharing enabled', 'Low sample drift', 'Sample step 4', 'info'], + ['MFA registration policy updated', 'No sample drift', 'Sample step 5', 'healthy'], +]; + +const evidenceItems = [ + { icon: 'lucide:shield-check', meta: 'Updated recently - demo value', title: 'Conditional Access policy' }, + { icon: 'lucide:user-check', meta: 'Sample admin change', title: 'Role assignment change' }, + { icon: 'lucide:share-2', meta: 'Updated recently - demo value', title: 'SharePoint sharing setting' }, + { icon: 'lucide:mail', meta: 'Updated recently - demo value', title: 'Mailbox audit configuration' }, + { icon: 'lucide:square-plus', meta: 'Sample new item', title: 'App registration added' }, +]; +--- + +
      +
      +
      + + +
      +
      + Sample tenant +
      + Static demo preview + Demo values +
      + +
      + +
      +
      +
      +

      Governance overview

      +

      Static posture and activity across Microsoft tenant governance work.

      +
      + Sample week +
      + +
      +
      +

      Overall posture

      + + 92% + Healthy +
      + +
      +

      Findings

      + 14 +

      Total

      + Review required + Critical 2 + High 5 + Medium 4 + Low 3 +
      + +
      +

      Drift detected

      + 7 +

      Resources

      + + Critical +
      + +
      +

      Evidence items

      + 1,248 +

      Collected

      + ▲ 18% vs sample week + Review-ready +
      + +
      +

      Backup status

      + 98% +

      Successful

      + + Healthy +
      +
      + +
      +
      +
      +

      Recent findings

      + Sample set +
      +
      + { + findings.map(([severity, title, area, time]) => ( +
      + +
      +

      {title}

      +

      {area}

      +
      + {severity} + {time} +
      + )) + } +
      +
      + +
      +
      +

      Drift timeline

      + Sample week +
      +
        + { + timeline.map(([title, description, time, tone]) => ( +
      1. + +
        +

        {title}

        + {description} +
        + +
      2. + )) + } +
      +
      + +
      +
      +
      +

      Backups and restores

      + Sample values +
      +
      +
      +

      Microsoft 365

      + Recent sample success +
      + 98% + +
      +
      +
      +

      Azure AD

      + Recent sample success +
      + 97% + +
      +
      + +
      +
      +

      Governance reviews

      + Sample reviews +
      +

      Quarterly access review Due in 5 days

      +

      Privileged roles review In progress

      +
      +
      +
      + +
      +
      +

      Evidence spotlight

      + Sample evidence +
      +
      + { + evidenceItems.map((item) => ( +
      + +

      {item.title}

      + {item.meta} +
      + )) + } +
      +
      +
      +
      +
      +
      +
      + + diff --git a/apps/website/src/components/content/Headline.astro b/apps/website/src/components/content/Headline.astro index 31ae6915..ca92fbbe 100644 --- a/apps/website/src/components/content/Headline.astro +++ b/apps/website/src/components/content/Headline.astro @@ -17,6 +17,6 @@ const sizeClasses = { }; --- -.accent]:text-[var(--color-primary)]', sizeClasses[size], className]}> +.accent]:text-[var(--color-primary)]', sizeClasses[size], className]}> diff --git a/apps/website/src/components/content/HeroDashboard.astro b/apps/website/src/components/content/HeroDashboard.astro deleted file mode 100644 index ce4c319e..00000000 --- a/apps/website/src/components/content/HeroDashboard.astro +++ /dev/null @@ -1,467 +0,0 @@ ---- ---- - - - - diff --git a/apps/website/src/components/content/TenantialLogo.astro b/apps/website/src/components/content/TenantialLogo.astro new file mode 100644 index 00000000..bb4b5ca1 --- /dev/null +++ b/apps/website/src/components/content/TenantialLogo.astro @@ -0,0 +1,35 @@ +--- +interface Props { + class?: string; + imageClass?: string; + invert?: boolean; + label?: string; + markClass?: string; +} + +const { + class: className = '', + imageClass, + invert = true, + label = 'Tenantial', + markClass = 'h-9 w-9', +} = Astro.props; + +const resolvedImageClass = imageClass ?? markClass; +--- + + + {label} + diff --git a/apps/website/src/components/layout/Footer.astro b/apps/website/src/components/layout/Footer.astro index 2ca0f1cc..54e33689 100644 --- a/apps/website/src/components/layout/Footer.astro +++ b/apps/website/src/components/layout/Footer.astro @@ -17,10 +17,10 @@ const footerNavigationGroups = await getFooterNavigationGroups();
      -

      +

      {footerLead.eyebrow}

      -

      +

      {footerLead.title}

      @@ -33,7 +33,7 @@ const footerNavigationGroups = await getFooterNavigationGroups(); { footerNavigationGroups.map((group) => (

      -

      +

      {group.title}

        @@ -52,9 +52,9 @@ const footerNavigationGroups = await getFooterNavigationGroups();
      -

      © {currentYear} {siteMetadata.siteName}. Core public route foundation.

      +

      © {currentYear} {siteMetadata.siteName}. Evidence-first governance for Microsoft tenants.

      - Built as a static Astro track with no platform auth, session, or API coupling. + Static public website with sample preview values only.

      diff --git a/apps/website/src/components/layout/Navbar.astro b/apps/website/src/components/layout/Navbar.astro index 5f707a06..a4c08eab 100644 --- a/apps/website/src/components/layout/Navbar.astro +++ b/apps/website/src/components/layout/Navbar.astro @@ -1,5 +1,6 @@ --- -import SecondaryCTA from '@/components/content/SecondaryCTA.astro'; +import PrimaryCTA from '@/components/content/PrimaryCTA.astro'; +import TenantialLogo from '@/components/content/TenantialLogo.astro'; import Container from '@/components/primitives/Container.astro'; import { getHeaderCta, getPrimaryNavigation, isActiveNavigationPath, siteMetadata } from '@/lib/site'; @@ -12,39 +13,31 @@ const headerCta = getHeaderCta(currentPath); const primaryNavigation = await getPrimaryNavigation(); --- -
      - +
      + diff --git a/apps/website/src/components/layout/PageShell.astro b/apps/website/src/components/layout/PageShell.astro index 661e1077..82ef0138 100644 --- a/apps/website/src/components/layout/PageShell.astro +++ b/apps/website/src/components/layout/PageShell.astro @@ -37,10 +37,15 @@ const pageDefinition = getPageDefinition(currentPath); data-surface-group={pageDefinition.surfaceGroup} data-journey-stage={pageDefinition.journeyStage} > -
      -
      +
      + { + pageDefinition.pageRole === 'home' && ( + <> + + + + ) + }
      diff --git a/apps/website/src/components/primitives/Button.astro b/apps/website/src/components/primitives/Button.astro index 229b2952..6876abba 100644 --- a/apps/website/src/components/primitives/Button.astro +++ b/apps/website/src/components/primitives/Button.astro @@ -34,10 +34,10 @@ const sizeClasses = { const variantClasses = { primary: - 'border-transparent bg-[var(--color-ink-900)] text-white shadow-[0_2px_8px_rgba(0,0,0,0.12)] hover:bg-[var(--color-ink-800)] hover:shadow-[0_4px_16px_rgba(0,0,0,0.18)] active:scale-[0.98]', + 'border-transparent bg-[var(--color-primary)] text-[var(--color-primary-foreground)] shadow-[0_2px_14px_rgba(111,229,191,0.2)] hover:bg-[var(--color-mint-300)] hover:shadow-[0_4px_20px_rgba(111,229,191,0.26)] active:scale-[0.98]', secondary: - 'border-[color:var(--color-border)] bg-white text-[var(--color-secondary-foreground)] hover:border-[var(--color-border-strong)] hover:bg-[var(--surface-muted)] active:scale-[0.98]', - ghost: 'border-transparent bg-transparent text-[var(--color-ink-800)] hover:bg-white/70', + 'border-[color:var(--color-border)] bg-[var(--color-secondary)] text-[var(--color-secondary-foreground)] hover:border-[var(--color-border-strong)] hover:bg-[var(--surface-muted-strong)] active:scale-[0.98]', + ghost: 'border-transparent bg-transparent text-[var(--color-muted-foreground)] hover:bg-[var(--surface-muted)] hover:text-[var(--color-foreground)]', }; const classes = [baseClass, sizeClasses[size], variantClasses[variant], className]; diff --git a/apps/website/src/components/sections/FeaturePillars.astro b/apps/website/src/components/sections/FeaturePillars.astro new file mode 100644 index 00000000..773fd80a --- /dev/null +++ b/apps/website/src/components/sections/FeaturePillars.astro @@ -0,0 +1,70 @@ +--- +import { Icon } from 'astro-icon/components'; +import Container from '@/components/primitives/Container.astro'; +import Section from '@/components/primitives/Section.astro'; +import SectionHeader from '@/components/primitives/SectionHeader.astro'; +import type { FeatureItemContent } from '@/types/site'; + +interface Props { + items: FeatureItemContent[]; +} + +const { items } = Astro.props; + +const lucideMap: Record = { + archive: 'lucide:archive', + refresh: 'lucide:refresh-cw', + 'git-branch': 'lucide:git-branch', + 'file-check': 'lucide:file-check', + clipboard: 'lucide:clipboard-list', + shield: 'lucide:shield-check', +}; +--- + +
      + +
      + + +
      + { + items.map((item) => { + const iconName = item.icon ? lucideMap[item.icon] : undefined; + + return ( +
      +
      + {iconName && ( + + )} +
      +

      + {item.title} +

      +

      + {item.description} +

      + {item.meta && ( +

      + {item.meta} +

      + )} +
      +
      +
      + ); + }) + } +
      +
      +
      +
      diff --git a/apps/website/src/components/sections/PageHero.astro b/apps/website/src/components/sections/PageHero.astro index ebe229fa..f9fc7683 100644 --- a/apps/website/src/components/sections/PageHero.astro +++ b/apps/website/src/components/sections/PageHero.astro @@ -3,12 +3,13 @@ import Badge from '@/components/primitives/Badge.astro'; import Card from '@/components/primitives/Card.astro'; import Cluster from '@/components/primitives/Cluster.astro'; import Container from '@/components/primitives/Container.astro'; +import DashboardPreview from '@/components/content/DashboardPreview.astro'; import Headline from '@/components/content/Headline.astro'; -import HeroDashboard from '@/components/content/HeroDashboard.astro'; import Lead from '@/components/content/Lead.astro'; import Metric from '@/components/content/Metric.astro'; import PrimaryCTA from '@/components/content/PrimaryCTA.astro'; import SecondaryCTA from '@/components/content/SecondaryCTA.astro'; +import { Icon } from 'astro-icon/components'; import type { HeroContent, MetricItem } from '@/types/site'; interface Props { @@ -23,11 +24,13 @@ const isHomepageHero = Astro.url.pathname === '/'; const heroHeadlineSize = isHomepageHero ? 'page' : 'display'; const heroLeadSize = isHomepageHero ? 'body' : 'lead'; const heroPrimaryAnchor = hero.primaryAnchor ?? 'headline'; +const trustSignalIcons = ['lucide:shield-check', 'lucide:lock', 'lucide:check-circle']; +const audienceLabels = ['MSP operators', 'Endpoint teams', 'Security reviewers', 'Audit owners', 'Cloud operations']; ---
      - + {isHomepageHero ? ( -
      -
      -
      -
      +
      +
      +
      +
      {hero.eyebrow} @@ -54,7 +57,7 @@ const heroPrimaryAnchor = hero.primaryAnchor ?? 'headline'; {hero.titleHtml ? : hero.title} @@ -64,7 +67,7 @@ const heroPrimaryAnchor = hero.primaryAnchor ?? 'headline'; data-hero-supporting-copy data-hero-segment="supporting-copy" > - + {hero.description}
      @@ -72,7 +75,7 @@ const heroPrimaryAnchor = hero.primaryAnchor ?? 'headline';
      {(hero.primaryCta || hero.secondaryCta) && (
      - + {hero.secondaryCta && ( @@ -81,9 +84,37 @@ const heroPrimaryAnchor = hero.primaryAnchor ?? 'headline';
      )}
      + {hero.trustSubclaims && hero.trustSubclaims.length > 0 && ( +
      +
        + {hero.trustSubclaims.map((claim, index) => ( +
      • +
      • + ))} +
      +
      +

      + Built for operator-led teams +

      +
        + {audienceLabels.map((label) => ( +
      • + {label} +
      • + ))} +
      +
      +
      + )}
      -
      - {hero.visualFocus && ( -
      -

      - {hero.visualFocus.eyebrow} -

      -

      - {hero.visualFocus.title} -

      -
        - {hero.visualFocus.points.map((point) => ( -
      • - {point} -
      • - ))} -
      -
      - )} - -
      +
      - {hero.trustSubclaims && hero.trustSubclaims.length > 0 && ( -
      -
        - {hero.trustSubclaims.map((claim) => ( -
      • - {claim} -
      • - ))} -
      -
      - )}
      ) : ( diff --git a/apps/website/src/components/sections/TrustBar.astro b/apps/website/src/components/sections/TrustBar.astro new file mode 100644 index 00000000..875ba8fb --- /dev/null +++ b/apps/website/src/components/sections/TrustBar.astro @@ -0,0 +1,30 @@ +--- +import Container from '@/components/primitives/Container.astro'; +import Section from '@/components/primitives/Section.astro'; +import type { TrustPrincipleContent } from '@/types/site'; + +interface Props { + statements: TrustPrincipleContent[]; +} + +const { statements } = Astro.props; +--- + +
      + +
      + { + statements.map((statement) => ( +
      +

      + {statement.title} +

      +

      + {statement.description} +

      +
      + )) + } +
      +
      +
      diff --git a/apps/website/src/content/pages/changelog.ts b/apps/website/src/content/pages/changelog.ts index de8d8b39..524e59c0 100644 --- a/apps/website/src/content/pages/changelog.ts +++ b/apps/website/src/content/pages/changelog.ts @@ -1,9 +1,9 @@ import type { HeroContent, PageSeo } from '@/types/site'; export const changelogSeo: PageSeo = { - title: 'TenantAtlas | Changelog', + title: 'Tenantial | Changelog', description: - 'TenantAtlas uses a dedicated changelog to show dated public progress without pretending a broader editorial or resources program is already live.', + 'Tenantial uses a dedicated changelog to show dated public progress without pretending a broader editorial or resources program is already live.', path: '/changelog', }; diff --git a/apps/website/src/content/pages/contact.ts b/apps/website/src/content/pages/contact.ts index 884ca85b..ac787c72 100644 --- a/apps/website/src/content/pages/contact.ts +++ b/apps/website/src/content/pages/contact.ts @@ -1,9 +1,9 @@ import type { HeroContent, LegalSection, PageSeo } from '@/types/site'; export const contactSeo: PageSeo = { - title: 'TenantAtlas | Contact', + title: 'Tenantial | Contact', description: - 'TenantAtlas uses one clear contact path for serious product, trust, and rollout conversations instead of splitting first contact across vague demo flows.', + 'Tenantial uses one clear contact path for serious product, trust, and rollout conversations instead of splitting first contact across vague demo flows.', path: '/contact', }; @@ -13,8 +13,8 @@ export const contactHero: HeroContent = { description: 'The contact path should help serious buyers explain who they are, what governance questions they are trying to solve, and what kind of follow-up would actually be useful.', primaryCta: { - href: 'mailto:hello@tenantatlas.example?subject=TenantAtlas%20working%20session', - label: 'Email the TenantAtlas team', + href: 'mailto:hello@tenantial.example?subject=Tenantial%20working%20session', + label: 'Email the Tenantial team', variant: 'primary', }, secondaryCta: { @@ -50,7 +50,7 @@ export const contactPrompts = [ export const contactPreview = { message: - 'We operate Microsoft tenant governance across multiple environments and want to understand how TenantAtlas approaches version history, safer restore flows, drift visibility, and review evidence.', + 'We operate Microsoft tenant governance across multiple environments and want to understand how Tenantial approaches version history, safer restore flows, drift visibility, and review evidence.', topic: 'Environment and operating model summary', }; diff --git a/apps/website/src/content/pages/home.ts b/apps/website/src/content/pages/home.ts index ce9b4a0b..fa5fe4de 100644 --- a/apps/website/src/content/pages/home.ts +++ b/apps/website/src/content/pages/home.ts @@ -1,171 +1,112 @@ import type { - CapabilityClusterContent, CtaLink, + FeatureItemContent, HeroContent, - IntegrationEntry, - OutcomeSectionContent, PageSeo, - ProgressTeaserContent, - TrustSignalGroupContent, + TrustPrincipleContent, } from '@/types/site'; export const homeSeo: PageSeo = { - title: 'TenantAtlas | Governance of record for Microsoft tenant operations', + title: 'Tenantial - Evidence-first governance for Microsoft tenants', description: - 'TenantAtlas helps teams understand Microsoft tenant change history, restore posture, trust boundaries, and the next evaluation step without a bloated public sitemap.', + 'Tenantial helps Microsoft tenant teams govern backup, restore, drift detection, snapshot-backed audit context, verifiable evidence, and structured governance reviews from one evidence-first surface.', + ogTitle: 'Evidence-first governance for Microsoft tenants.', + ogDescription: + 'Evidence-oriented restore discipline, drift detection, and governance review workflows for Microsoft tenants without unsupported proof claims.', path: '/', }; export const homeHero: HeroContent = { - eyebrow: 'Microsoft tenant governance', - title: 'TenantAtlas gives Microsoft tenant teams one operating record for change history, drift review, and restore planning.', + eyebrow: 'Governance that earns trust', + title: 'Evidence-first governance for Microsoft tenants.', description: - 'Security, endpoint, and platform teams use TenantAtlas to see what changed, preview restores, and move reviews forward without stitching governance across exports and memory.', + 'Backup and restore with confidence. Detect drift before it becomes audit work. Preserve snapshot history and review context for governance conversations that need evidence.', primaryCta: { href: '/contact', - label: 'Request a working session', + label: 'Book a demo', }, secondaryCta: { href: '/product', - label: 'See the product model', + label: 'Explore the platform', variant: 'secondary', }, - productVisual: { - src: '/images/hero-product-visual.svg', - alt: 'TenantAtlas screen showing change history, restore preview, and a review queue for Microsoft tenant policies', - }, trustSubclaims: [ - 'Tenant-scoped boundaries', - 'Reviewable change history', - 'Preview before restore', + 'Built for Microsoft 365 and Azure AD', + 'Snapshot-first history with review context.', + 'Designed for audit-ready workflows.', ], }; -export const homeOutcome: OutcomeSectionContent = { - title: 'Calmer operations start with visible governance.', - description: - 'TenantAtlas replaces fragmented spreadsheets and manual audit trails with one connected record of tenant change, restore safety, and review context.', - audienceBias: 'MSP and enterprise operations teams', - outcomes: [ - { - title: 'Understand what changed and why it matters.', - description: - 'Immutable version history gives every tenant configuration a traceable timeline, so drift and unexpected changes surface before they become incidents.', - }, - { - title: 'Restore with confidence, not guesswork.', - description: - 'Preview-first restore flows validate scope and impact before execution, turning risky rollbacks into reviewable operations.', - }, - { - title: 'Reduce the operational risk of governance gaps.', - description: - 'Connected findings, evidence, and review workflows keep audit context in one place instead of scattered across tools and memory.', - }, - ], -}; - -export const homeCapabilities: CapabilityClusterContent[] = [ +export const homeTrustBar: TrustPrincipleContent[] = [ { - title: 'Backup & Version History', - description: 'Immutable snapshots of tenant configuration state with full version lineage.', - capabilities: ['Automated backup', 'Immutable snapshots', 'Version comparison', 'Change timeline'], - href: '/product', - meta: 'Core safety net', + title: 'Microsoft tenant focused', + description: 'Built around the governance surfaces Microsoft tenant teams already need to inspect.', }, { - title: 'Restore & Recovery', - description: 'Preview-first restore with selective scope and explicit confirmation.', - capabilities: ['Dry-run preview', 'Selective restore', 'Rollback safety', 'Conflict detection'], - href: '/product', - meta: 'Safer change operations', + title: 'Evidence-oriented workflows', + description: 'Backup, drift, restore, and review context stay connected to the decision trail.', }, { - title: 'Inventory & Drift Visibility', - description: 'Connected view of what exists, what drifted, and what needs attention.', - capabilities: ['Policy inventory', 'Drift detection', 'Assignment visibility', 'Cross-tenant comparison'], - href: '/product', - meta: 'Operational clarity', + title: 'Designed for audit review', + description: 'Claims stay grounded in reviewable evidence rather than unverified certification language.', }, { - title: 'Governance & Evidence', - description: 'Audit-ready evidence, findings, and review workflows for tenant operations.', - capabilities: ['Audit trails', 'Evidence linkage', 'Exception tracking', 'Review workflows'], - href: '/product', - meta: 'Accountability and review', + title: 'Operator-led governance', + description: 'High-risk actions stay framed by preview, confirmation, and explicit accountability.', }, ]; -export const homeTrustSignals: TrustSignalGroupContent = { - title: 'Trust is a first-read concern, not a footnote.', - description: - 'Tenant isolation, access boundaries, and operating discipline are product rules, not marketing language. Every public claim routes back to one bounded trust surface.', - supportRoute: '/trust', - signals: [ - { - title: 'Tenant isolation is a product boundary.', - description: - 'Tenant-scoped data, access, and workflow boundaries are enforced as deliberate product rules.', - }, - { - title: 'Access stays bounded and purpose-specific.', - description: - 'Microsoft tenant-facing access follows least-privilege scoping tied to the governance operations that require it.', - }, - { - title: 'Restore operations require preview and confirmation.', - description: - 'High-risk changes go through validation, selective scope, and explicit confirmation instead of one-click execution.', - }, - ], -}; - -export const homeProgressTeaser: ProgressTeaserContent = { - title: 'Product movement you can verify.', - description: - 'Real progress shows up as dated changelog entries, not vague promises. Check the changelog for the latest updates.', - entries: [], - cta: { - href: '/changelog', - label: 'Read the full changelog', +export const homeFeaturePillars: FeatureItemContent[] = [ + { + title: 'Backup', + description: 'Preserve full Microsoft tenant configuration snapshots so recovery starts from known evidence.', + icon: 'archive', + meta: 'Snapshot record', }, -}; + { + title: 'Restore', + description: 'Preview scope, assignments, and conflicts before an operator commits to a restore path.', + icon: 'refresh', + meta: 'Dry-run first', + }, + { + title: 'Drift Detection', + description: 'Surface meaningful configuration change so review work starts before drift becomes incident work.', + icon: 'git-branch', + meta: 'Review required', + }, + { + title: 'Evidence', + description: 'Keep screenshots, snapshots, findings, and review notes attached to the governance decision.', + icon: 'file-check', + meta: 'Decision context', + }, + { + title: 'Audit Trail', + description: 'Show who changed, reviewed, backed up, or restored tenant configuration with clear timestamps.', + icon: 'clipboard', + meta: 'Traceable activity', + }, + { + title: 'Governance Reviews', + description: 'Move structured tenant reviews from memory and exports into a repeatable operating rhythm.', + icon: 'shield', + meta: 'Review cadence', + }, +]; export const homeCtaSection = { eyebrow: 'Next step', - title: 'Move from first read into a working session.', + title: 'Build tenant governance on evidence, not assumptions.', description: - 'Once you understand the product model, the trust posture, and the progress record, the next step is a conversation about your governance reality.', + 'Use a focused walkthrough to map Tenantial against your Microsoft tenant backup, restore, drift, evidence, and review workflows.', primary: { href: '/contact', - label: 'Request a working session', + label: 'Book a demo', } as CtaLink, secondary: { href: '/product', - label: 'See the product model', + label: 'Explore the platform', variant: 'secondary', } as CtaLink, }; - -export const homeEcosystem: IntegrationEntry[] = [ - { - category: 'Microsoft', - name: 'Microsoft Graph', - summary: 'Graph-backed inventory and restore without implying the public website depends on live tenant access.', - }, - { - category: 'Identity', - name: 'Entra ID', - summary: 'Identity context where change control, tenant access, and review posture intersect.', - }, - { - category: 'Endpoint', - name: 'Intune', - summary: 'Configuration state, backup, restore posture, and drift visibility stay central to the product story.', - }, - { - category: 'Governance', - name: 'Review workflows', - summary: 'Exceptions, evidence, and reviews stay connected to operational reality.', - }, -]; diff --git a/apps/website/src/content/pages/imprint.ts b/apps/website/src/content/pages/imprint.ts index a7352e41..2ff3bad8 100644 --- a/apps/website/src/content/pages/imprint.ts +++ b/apps/website/src/content/pages/imprint.ts @@ -1,15 +1,15 @@ import type { HeroContent, LegalSection, PageSeo } from '@/types/site'; export const imprintSeo: PageSeo = { - title: 'TenantAtlas | Imprint', + title: 'Tenantial | Imprint', description: - 'TenantAtlas uses the Imprint route as the canonical public legal notice and publisher baseline for the website.', + 'Tenantial uses the Imprint route as the canonical public legal notice and publisher baseline for the website.', path: '/imprint', }; export const imprintHero: HeroContent = { eyebrow: 'Canonical legal notice', - title: 'Imprint and public legal notice baseline for the TenantAtlas website.', + title: 'Imprint and public legal notice baseline for the Tenantial website.', description: 'This route is the canonical public notice surface for publisher identity and jurisdiction-specific disclosure details. During controlled evaluation, it also makes clear which launch-ready fields still need to be finalized before broader publication.', primaryCta: { @@ -32,7 +32,7 @@ export const imprintSections: LegalSection[] = [ { title: 'Current publication status', body: [ - 'TenantAtlas is still tightening its launch-ready publisher and jurisdiction details for the broader public website.', + 'Tenantial is still tightening its launch-ready publisher and jurisdiction details for the broader public website.', 'Until those fields are finalized, this route makes the intended legal-notice location explicit so the public IA stays honest about where the canonical notice belongs.', ], bullets: [ diff --git a/apps/website/src/content/pages/integrations.ts b/apps/website/src/content/pages/integrations.ts index b204afa8..9bb0eaf8 100644 --- a/apps/website/src/content/pages/integrations.ts +++ b/apps/website/src/content/pages/integrations.ts @@ -6,9 +6,9 @@ import type { } from '@/types/site'; export const integrationsSeo: PageSeo = { - title: 'TenantAtlas | Integrations', + title: 'Tenantial | Integrations', description: - 'TenantAtlas keeps ecosystem-fit detail available as a supporting page without pretending integrations belong in the primary navigation.', + 'Tenantial keeps ecosystem-fit detail available as a supporting page without pretending integrations belong in the primary navigation.', path: '/integrations', }; @@ -16,7 +16,7 @@ export const integrationsHero: HeroContent = { eyebrow: 'Retained supporting page', title: 'Keep ecosystem fit detail visible without pretending it is the first thing every buyer needs.', description: - 'This page shows the real systems TenantAtlas is built around, the workflows it expects to support, and the deliberate boundaries where speculative integration language would create more noise than trust.', + 'This page shows the real systems Tenantial is built around, the workflows it expects to support, and the deliberate boundaries where speculative integration language would create more noise than trust.', primaryCta: { href: '/contact', label: 'Plan the working session', diff --git a/apps/website/src/content/pages/legal.ts b/apps/website/src/content/pages/legal.ts index 768175f1..5f493e7d 100644 --- a/apps/website/src/content/pages/legal.ts +++ b/apps/website/src/content/pages/legal.ts @@ -1,9 +1,9 @@ import type { HeroContent, LegalSection, PageSeo } from '@/types/site'; export const legalSeo: PageSeo = { - title: 'TenantAtlas | Legal', + title: 'Tenantial | Legal', description: - 'The TenantAtlas legal baseline keeps privacy, terms, imprint, and trust routing accessible without promoting the legal hub into the primary navigation.', + 'The Tenantial legal baseline keeps privacy, terms, imprint, and trust routing accessible without promoting the legal hub into the primary navigation.', path: '/legal', }; diff --git a/apps/website/src/content/pages/privacy.ts b/apps/website/src/content/pages/privacy.ts index 93940ae1..ff72e38d 100644 --- a/apps/website/src/content/pages/privacy.ts +++ b/apps/website/src/content/pages/privacy.ts @@ -1,15 +1,15 @@ import type { HeroContent, LegalSection, PageSeo } from '@/types/site'; export const privacySeo: PageSeo = { - title: 'TenantAtlas | Privacy', + title: 'Tenantial | Privacy', description: - 'Public-site privacy overview for TenantAtlas inquiries, including how contact details and evaluation context are handled on the public website.', + 'Public-site privacy overview for Tenantial inquiries, including how contact details and evaluation context are handled on the public website.', path: '/privacy', }; export const privacyHero: HeroContent = { eyebrow: 'Privacy', - title: 'Public-site privacy overview for TenantAtlas inquiries.', + title: 'Public-site privacy overview for Tenantial inquiries.', description: 'This page explains the privacy expectations for the public website and the contact path, rather than promising a full product-tenant data processing agreement from a static marketing surface.', primaryCta: { @@ -32,7 +32,7 @@ export const privacySections: LegalSection[] = [ { title: 'Scope', body: [ - 'This privacy overview applies to the public TenantAtlas website and to information a visitor intentionally shares through the public contact path.', + 'This privacy overview applies to the public Tenantial website and to information a visitor intentionally shares through the public contact path.', 'It does not describe tenant data processing inside the product itself, which belongs in product-specific legal and contractual materials.', ], }, diff --git a/apps/website/src/content/pages/product.ts b/apps/website/src/content/pages/product.ts index 6d9d040d..8f8e4a14 100644 --- a/apps/website/src/content/pages/product.ts +++ b/apps/website/src/content/pages/product.ts @@ -7,9 +7,9 @@ import type { } from '@/types/site'; export const productSeo: PageSeo = { - title: 'TenantAtlas | Product', + title: 'Tenantial | Product', description: - 'TenantAtlas explains backup, restore, version history, drift, findings, evidence, and reviews as one operating model rather than a loose feature list.', + 'Tenantial explains backup, restore, version history, drift, findings, evidence, and reviews as one operating model rather than a loose feature list.', path: '/product', }; @@ -17,7 +17,7 @@ export const productHero: HeroContent = { eyebrow: 'Product model', title: 'Explain the product as one operating model before asking a buyer to trust the route map around it.', description: - 'TenantAtlas treats Microsoft tenant governance as one connected system: observe the current state, preserve immutable history, detect meaningful change, and support reviews or restores with the context operators actually need.', + 'Tenantial treats Microsoft tenant governance as one connected system: observe the current state, preserve immutable history, detect meaningful change, and support reviews or restores with the context operators actually need.', primaryCta: { href: '/trust', label: 'Review the trust posture', diff --git a/apps/website/src/content/pages/security-trust.ts b/apps/website/src/content/pages/security-trust.ts index a570383b..cd937e30 100644 --- a/apps/website/src/content/pages/security-trust.ts +++ b/apps/website/src/content/pages/security-trust.ts @@ -6,9 +6,9 @@ import type { } from '@/types/site'; export const securityTrustSeo: PageSeo = { - title: 'TenantAtlas | Security & Trust', + title: 'Tenantial | Security & Trust', description: - 'TenantAtlas frames trust through substantiated product posture, safer restore discipline, and operational clarity rather than inflated guarantees.', + 'Tenantial frames trust through substantiated product posture, safer restore discipline, and operational clarity rather than inflated guarantees.', path: '/security-trust', }; diff --git a/apps/website/src/content/pages/solutions.ts b/apps/website/src/content/pages/solutions.ts index 5e42c82d..2ab5d8ed 100644 --- a/apps/website/src/content/pages/solutions.ts +++ b/apps/website/src/content/pages/solutions.ts @@ -6,9 +6,9 @@ import type { } from '@/types/site'; export const solutionsSeo: PageSeo = { - title: 'TenantAtlas | Solutions', + title: 'Tenantial | Solutions', description: - 'TenantAtlas keeps MSP and enterprise outcome framing available as a supporting page without requiring it for the first public read.', + 'Tenantial keeps MSP and enterprise outcome framing available as a supporting page without requiring it for the first public read.', path: '/solutions', }; @@ -79,7 +79,7 @@ export const solutionsSignals: FeatureItemContent[] = [ eyebrow: 'Where it lands', title: 'The product belongs in the operating layer, not just the reporting layer.', description: - 'Visitors should understand that TenantAtlas helps teams make safer decisions about configuration state rather than merely summarize activity afterward.', + 'Visitors should understand that Tenantial helps teams make safer decisions about configuration state rather than merely summarize activity afterward.', }, { eyebrow: 'How it reads', diff --git a/apps/website/src/content/pages/terms.ts b/apps/website/src/content/pages/terms.ts index 54ce9bfc..69fc0131 100644 --- a/apps/website/src/content/pages/terms.ts +++ b/apps/website/src/content/pages/terms.ts @@ -1,15 +1,15 @@ import type { HeroContent, LegalSection, PageSeo } from '@/types/site'; export const termsSeo: PageSeo = { - title: 'TenantAtlas | Terms', + title: 'Tenantial | Terms', description: - 'Website terms for the public TenantAtlas surface, covering informational use of the site and the limits of public product statements.', + 'Website terms for the public Tenantial surface, covering informational use of the site and the limits of public product statements.', path: '/terms', }; export const termsHero: HeroContent = { eyebrow: 'Website terms', - title: 'Website terms for the public TenantAtlas surface.', + title: 'Website terms for the public Tenantial surface.', description: 'These terms describe the public website itself: informational use of the content, basic conduct expectations, and the fact that a public product site is not the same thing as a signed service agreement.', primaryCta: { @@ -32,7 +32,7 @@ export const termsSections: LegalSection[] = [ { title: 'Informational website use', body: [ - 'The public TenantAtlas website is provided to explain the product category, trust posture, integrations direction, and contact path for evaluation conversations.', + 'The public Tenantial website is provided to explain the product category, trust posture, integrations direction, and contact path for evaluation conversations.', 'Nothing on the public site should be interpreted as a guarantee of product availability, certification, or commercial commitment unless it is later confirmed in signed agreements.', ], }, diff --git a/apps/website/src/content/pages/trust.ts b/apps/website/src/content/pages/trust.ts index c1748a4e..a5a795fd 100644 --- a/apps/website/src/content/pages/trust.ts +++ b/apps/website/src/content/pages/trust.ts @@ -6,9 +6,9 @@ import type { } from '@/types/site'; export const trustSeo: PageSeo = { - title: 'TenantAtlas | Trust', + title: 'Tenantial | Trust', description: - 'TenantAtlas uses the Trust surface to explain tenant isolation, access boundaries, operating discipline, and the limits of public claims in one explicit place.', + 'Tenantial uses the Trust surface to explain tenant isolation, access boundaries, operating discipline, and the limits of public claims in one explicit place.', path: '/trust', }; diff --git a/apps/website/src/lib/site.ts b/apps/website/src/lib/site.ts index 6aab015d..35ec67fb 100644 --- a/apps/website/src/lib/site.ts +++ b/apps/website/src/lib/site.ts @@ -16,11 +16,11 @@ import type { } from '@/types/site'; export const siteMetadata: SiteMetadata = { - siteName: 'TenantAtlas', - siteTagline: 'Governance of record for Microsoft tenant operations.', + siteName: 'Tenantial', + siteTagline: 'Evidence-first governance for Microsoft tenants.', siteDescription: - 'TenantAtlas helps MSP and enterprise teams keep Microsoft tenant change history observable, reviewable, and safer to operate.', - siteUrl: import.meta.env.PUBLIC_SITE_URL ?? 'https://tenantatlas.example', + 'Tenantial helps MSP and enterprise teams govern Microsoft tenant backup, restore, drift, evidence, audit trails, and structured reviews from one evidence-first surface.', + siteUrl: import.meta.env.PUBLIC_SITE_URL ?? 'https://tenantial.example', }; export const visualFoundationContract: VisualFoundationContract = { @@ -68,17 +68,17 @@ interface FooterGroupSeed { export const contactCta: CtaLink = { href: '/contact', - label: 'Request a working session', - helper: 'Bring your governance questions, rollout concerns, or evaluation goals.', + label: 'Book a demo', + helper: 'Bring tenant governance questions, restore concerns, or audit-readiness goals.', variant: 'primary', }; const footerLeadByFamily: Record = { landing: { - eyebrow: 'Keep the next move obvious', - title: 'Product, trust, progress, and contact should stay connected.', + eyebrow: 'Next step', + title: 'Build tenant governance on evidence, not assumptions.', description: - 'Landing pages should move visitors from orientation into product understanding, trust review, changelog proof, or the contact path without fake maturity signals.', + 'Use a focused demo conversation to map Tenantial against your Microsoft tenant backup, restore, drift, evidence, and review needs.', intent: 'conversion', primaryCta: contactCta, }, @@ -90,7 +90,7 @@ const footerLeadByFamily: Record = { intent: 'guidance', primaryCta: { href: '/contact', - label: 'Discuss trust requirements', + label: 'Book a demo', helper: 'Bring current review, legal, or rollout questions into one working conversation.', variant: 'primary', }, @@ -103,7 +103,7 @@ const footerLeadByFamily: Record = { intent: 'legal', primaryCta: { href: '/contact', - label: 'Continue the evaluation path', + label: 'Book a demo', helper: 'Move from the reading surface back into a product or trust conversation.', variant: 'primary', }, @@ -113,19 +113,19 @@ const footerLeadByFamily: Record = { const headerCtaByFamily: Record = { landing: { href: '/contact', - label: 'Request a working session', - helper: 'Bring your governance questions, rollout concerns, or evaluation goals.', + label: 'Book a demo', + helper: 'Discuss the tenant governance surface with the Tenantial team.', variant: 'secondary', }, trust: { href: '/contact', - label: 'Discuss trust requirements', + label: 'Book a demo', helper: 'Route trust, legal, or rollout questions into one conversation.', variant: 'secondary', }, content: { href: '/contact', - label: 'Start the contact path', + label: 'Book a demo', helper: 'Turn the reading path into a concrete next step.', variant: 'secondary', }, @@ -157,38 +157,55 @@ export async function getSurfaceAvailability(): Promise { } const primaryNavigationSeeds: CollectionGatedNavigationItem[] = [ - { href: '/product', label: 'Product', description: 'See how the product connects backup, restore, drift, and evidence.' }, - { href: '/trust', label: 'Trust', description: 'Review the operating posture and bounded public claims.' }, - { href: '/changelog', label: 'Changelog', description: 'Inspect dated product progress instead of placeholder content.' }, - { href: '/resources', label: 'Resources', description: 'Optional deeper content when substantive material exists.', collection: 'resources' }, - { href: '/contact', label: 'Contact', description: 'Move into a working session with one clear next step.' }, + { href: '/product', label: 'Platform', description: 'Explore the evidence-first governance surface.' }, + { href: '/solutions', label: 'Solutions', description: 'Review MSP and enterprise governance fit.' }, + { href: '/changelog', label: 'Resources', description: 'Use the changelog as the current public resource baseline.' }, + { href: '/contact', label: 'Pricing', description: 'Pricing is handled through a scoped demo conversation.' }, + { href: '/contact', label: 'Company', description: 'Contact is the current company and team introduction path.' }, ]; const footerNavigationGroupSeeds: FooterGroupSeed[] = [ { - title: 'Product', - items: [ - { href: '/product', label: 'Product' }, - { href: '/changelog', label: 'Changelog' }, - ], + title: 'Platform', + items: [{ href: '/product', label: 'Explore the platform' }], }, { - title: 'Trust & Legal', - items: [ - { href: '/trust', label: 'Trust' }, - { href: '/privacy', label: 'Privacy' }, - { href: '/imprint', label: 'Imprint' }, - { href: '/terms', label: 'Terms' }, - ], + title: 'Solutions', + items: [{ href: '/solutions', label: 'Solutions' }], + }, + { + title: 'Resources', + items: [{ href: '/changelog', label: 'Changelog' }], + }, + { + title: 'Pricing', + items: [{ href: '/contact', label: 'Book a demo' }], + }, + { + title: 'Company', + items: [{ href: '/contact', label: 'Contact' }], }, { title: 'Contact', items: [{ href: '/contact', label: 'Contact' }], }, { - title: 'Content', - collection: 'resources', - items: [{ href: '/resources', label: 'Resources', collection: 'resources' }], + title: 'Legal', + items: [ + { href: '/legal', label: 'Legal' }, + { href: '/imprint', label: 'Imprint' }, + ], + }, + { + title: 'Privacy', + items: [{ href: '/privacy', label: 'Privacy' }], + }, + { + title: 'Security', + items: [ + { href: '/trust', label: 'Trust' }, + { href: '/terms', label: 'Terms' }, + ], }, ]; diff --git a/apps/website/src/pages/index.astro b/apps/website/src/pages/index.astro index 28d816ab..42e5cbd6 100644 --- a/apps/website/src/pages/index.astro +++ b/apps/website/src/pages/index.astro @@ -1,87 +1,24 @@ --- import PageShell from '@/components/layout/PageShell.astro'; -import CapabilityGrid from '@/components/sections/CapabilityGrid.astro'; import CTASection from '@/components/sections/CTASection.astro'; -import LogoStrip from '@/components/sections/LogoStrip.astro'; -import OutcomeSection from '@/components/sections/OutcomeSection.astro'; +import FeaturePillars from '@/components/sections/FeaturePillars.astro'; import PageHero from '@/components/sections/PageHero.astro'; -import ProgressTeaser from '@/components/sections/ProgressTeaser.astro'; -import TrustGrid from '@/components/sections/TrustGrid.astro'; -import PrimaryCTA from '@/components/content/PrimaryCTA.astro'; -import Container from '@/components/primitives/Container.astro'; -import Section from '@/components/primitives/Section.astro'; -import { getRecentChangelogEntries } from '@/lib/changelog'; +import TrustBar from '@/components/sections/TrustBar.astro'; import { - homeCapabilities, homeCtaSection, - homeEcosystem, + homeFeaturePillars, homeHero, - homeOutcome, - homeProgressTeaser, homeSeo, - homeTrustSignals, + homeTrustBar, } from '@/content/pages/home'; - -const recentChangelog = await getRecentChangelogEntries(3); -const progressContent = { - ...homeProgressTeaser, - entries: recentChangelog.length > 0 ? recentChangelog : homeProgressTeaser.entries, -}; --- - + - - - - -
      - -
      - -
      - -
      -
      -
      -
      - - {progressContent.entries.length > 0 && ( - - )} - {progressContent.entries.length === 0 && ( -
      - -
      -

      - Follow product progress on the changelog. -

      -
      -
      -
      - )} +
      { +test('core IA publishes Tenantial homepage navigation without dead template routes', async ({ page }) => { await visitPage(page, '/'); const header = page.getByRole('banner'); const footer = page.getByRole('contentinfo'); - await expect(header.getByRole('link', { name: 'Resources' })).toHaveCount(0); - await expect(header.getByRole('link', { name: 'Solutions' })).toHaveCount(0); - await expect(header.getByRole('link', { name: 'Integrations' })).toHaveCount(0); + await expect(header.getByRole('link', { name: 'Platform', exact: true })).toHaveAttribute('href', '/product'); + await expect(header.getByRole('link', { name: 'Solutions', exact: true })).toHaveAttribute('href', '/solutions'); + await expect(header.getByRole('link', { name: 'Resources', exact: true })).toHaveAttribute('href', '/changelog'); + await expect(header.getByRole('link', { name: 'Pricing', exact: true })).toHaveAttribute('href', '/contact'); + await expect(header.getByRole('link', { name: 'Company', exact: true })).toHaveAttribute('href', '/contact'); + await expect(header.getByRole('link', { name: 'Book a demo', exact: true })).toHaveAttribute('href', '/contact'); + await expect(header.locator('[data-nav-state="deferred"]').filter({ hasText: 'Sign in' }).first()).toBeVisible(); await expect(header.getByRole('link', { name: 'Security & Trust' })).toHaveCount(0); - await expect(footer.getByRole('link', { name: 'Resources' })).toHaveCount(0); + for (const group of ['Platform', 'Solutions', 'Resources', 'Pricing', 'Company', 'Contact', 'Legal', 'Privacy', 'Security']) { + await expect(footer.getByText(group, { exact: true }).first()).toBeVisible(); + } + await expect(footer.getByRole('link', { name: 'Articles' })).toHaveCount(0); await expect(footer.getByRole('link', { name: 'Security & Trust' })).toHaveCount(0); }); diff --git a/apps/website/tests/smoke/contact-legal.spec.ts b/apps/website/tests/smoke/contact-legal.spec.ts index 0d2b067d..85486968 100644 --- a/apps/website/tests/smoke/contact-legal.spec.ts +++ b/apps/website/tests/smoke/contact-legal.spec.ts @@ -26,7 +26,7 @@ test('contact page qualifies the conversation and keeps legal links reachable', name: 'Structure the first conversation before anyone shares sensitive context.', }), ).toBeVisible(); - await expect(page.getByRole('main').getByRole('link', { name: 'Email the TenantAtlas team' }).first()).toBeVisible(); + await expect(page.getByRole('main').getByRole('link', { name: 'Email the Tenantial team' }).first()).toBeVisible(); await expect(page.getByRole('main').getByRole('link', { name: 'Review the trust posture' }).first()).toBeVisible(); await expect(page.getByRole('main').getByRole('link', { name: 'Privacy' }).first()).toBeVisible(); await expect(page.getByRole('main').getByRole('link', { name: 'Terms' }).first()).toBeVisible(); @@ -77,9 +77,9 @@ test.describe('mobile navigation', () => { await visitPage(page, '/'); await openMobileNavigation(page); await expect(page.locator('[data-mobile-nav]').first()).toBeVisible(); - await expect(page.getByRole('banner').getByRole('link', { name: /Contact/ }).first()).toBeVisible(); - await expect(page.getByRole('banner').getByRole('link', { name: 'Trust' }).first()).toBeVisible(); - await expect(page.getByRole('banner').getByRole('link', { name: 'Changelog' }).first()).toBeVisible(); + await expect(page.getByRole('banner').getByRole('link', { name: 'Book a demo' }).first()).toBeVisible(); + await expect(page.getByRole('banner').getByRole('link', { name: 'Platform' }).first()).toBeVisible(); + await expect(page.getByRole('banner').getByRole('link', { name: 'Resources' }).first()).toBeVisible(); await expect(page.getByRole('contentinfo').getByRole('link', { name: 'Privacy' })).toBeVisible(); await expect(page.getByRole('contentinfo').getByRole('link', { name: 'Terms' })).toBeVisible(); await expect(page.getByRole('contentinfo').getByRole('link', { name: 'Imprint' })).toBeVisible(); diff --git a/apps/website/tests/smoke/home-product.spec.ts b/apps/website/tests/smoke/home-product.spec.ts index c5ac98f1..81332762 100644 --- a/apps/website/tests/smoke/home-product.spec.ts +++ b/apps/website/tests/smoke/home-product.spec.ts @@ -8,32 +8,59 @@ import { expectHomepageHeroOrder, expectHomepageHeroRouteTargets, expectHomepageHeroStructure, - expectHomepageHeroTrustSignals, expectHomepageHeroVisibleOnMobile, expectHomepageSectionOrder, expectMobileReadability, expectNavigationVsCtaDifferentiation, + expectNoBodyHorizontalOverflow, expectOnwardRouteReachable, expectPageFamily, - expectProductNearVisual, expectPrimaryNavigation, expectShell, visitPage, } from './smoke-helpers'; -test('home uses the landing foundation to explain the product category with one clear action hierarchy', async ({ - page, -}) => { +const forbiddenHomepageTerms = [ + 'AstroDeck', + 'Open Source', + 'MIT Licensed', + 'TenantCTRL', + 'TenantPilot', + 'TenantAtlas', +] as const; + +async function expectForbiddenHomepageResidueAbsent(page: import('@playwright/test').Page): Promise { + const body = page.locator('body'); + const metadata = await page.locator('head').evaluate((head) => { + const title = document.title; + const meta = Array.from(head.querySelectorAll('meta')) + .map((element) => element.getAttribute('content') ?? '') + .join(' '); + const canonical = head.querySelector('link[rel="canonical"]')?.getAttribute('href') ?? ''; + + return `${title} ${meta} ${canonical}`; + }); + + for (const term of forbiddenHomepageTerms) { + await expect(body).not.toContainText(new RegExp(term, 'i')); + expect(metadata, `Homepage metadata should not contain ${term}`).not.toMatch(new RegExp(term, 'i')); + } +} + +test('home first read positions Tenantial with one clear action hierarchy', async ({ page }) => { await visitPage(page, '/'); - await expectShell(page, /TenantAtlas/); + await expectShell(page, 'Evidence-first governance for Microsoft tenants.'); await expectPageFamily(page, 'landing'); await expectDisclosureLayer(page, '1'); await expectDisclosureLayer(page, '2'); await expectPrimaryNavigation(page); await expectNavigationVsCtaDifferentiation(page); await expectFooterLinks(page); - await expectCtaHierarchy(page, 'Request a working session', 'See the product model'); - await expect(page.getByRole('main').getByRole('link', { name: 'Request a working session' }).first()).toBeVisible(); + await expectCtaHierarchy(page, 'Book a demo', 'Explore the platform'); + await expect(page.getByRole('main').getByRole('link', { name: 'Book a demo' }).first()).toBeVisible(); + await expect(page.getByRole('main').getByRole('link', { name: 'Explore the platform' }).first()).toBeVisible(); + await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1); + await expectForbiddenHomepageResidueAbsent(page); const skipLink = page.getByRole('link', { name: 'Skip to content' }); @@ -41,102 +68,95 @@ test('home uses the landing foundation to explain the product category with one await expect(skipLink).toBeFocused(); }); -test('homepage hero explains the product with a product-near visual and outcome framing', async ({ - page, -}) => { - await visitPage(page, '/'); - await expectProductNearVisual(page); - await expect(page.locator('[data-section="outcome"]')).toBeVisible(); - await expect( - page.getByRole('heading', { name: /calmer operations|governance pain|operational risk/i }).first(), - ).toBeVisible(); - await expectCtaHierarchy(page, 'Request a working session', 'See the product model'); -}); - -test('homepage maintains required section order: outcome before capability before trust before progress before cta', async ({ - page, -}) => { - await visitPage(page, '/'); - await expectHomepageSectionOrder(page, ['outcome', 'capability', 'trust', 'progress', 'cta']); -}); - -test('homepage shows grouped capability clusters instead of a feature wall', async ({ - page, -}) => { - await visitPage(page, '/'); - await expect(page.locator('[data-section="capability"]')).toBeVisible(); - await expect( - page.getByRole('heading', { name: /product model|what TenantAtlas covers/i }), - ).toBeVisible(); -}); - -test('homepage shows explicit trust signals before the final CTA', async ({ - page, -}) => { - await visitPage(page, '/'); - await expect(page.locator('[data-section="trust"]')).toBeVisible(); - await expectOnwardRouteReachable(page, ['/trust']); -}); - -test('homepage hero makes the product category, text core, and one CTA pair explicit on first read', async ({ - page, -}) => { +test('homepage hero explains Tenantial, Microsoft tenant context, and required CTA routes', async ({ page }) => { await visitPage(page, '/'); await expectHomepageHeroStructure(page); - await expect(page.locator('[data-homepage-hero="true"] [data-hero-eyebrow]')).toContainText(/microsoft tenant governance/i); + await expect(page.locator('[data-homepage-hero="true"] [data-hero-eyebrow]')).toContainText( + /governance that earns trust/i, + ); await expect( page.locator('[data-homepage-hero="true"] [data-hero-heading]').getByRole('heading', { level: 1, - name: /one operating record for change history, drift review, and restore planning/i, + name: 'Evidence-first governance for Microsoft tenants.', }), ).toBeVisible(); await expect(page.locator('[data-homepage-hero="true"] [data-hero-supporting-copy]')).toContainText( - /security, endpoint, and platform teams use TenantAtlas to see what changed, preview restores, and move reviews forward/i, + /backup and restore with confidence\. detect drift before it becomes audit work\. preserve snapshot history/i, ); - await expectHomepageHeroCtaPair(page, /working session/i, /product model/i); + await expectHomepageHeroCtaPair(page, 'Book a demo', 'Explore the platform'); + await expectHomepageHeroRouteTargets(page, ['/contact', '/product']); }); -test('homepage hero keeps product-near proof and bounded trust cues inside the hero itself', async ({ +test('homepage uses neutral trust statements and the six required feature pillars', async ({ page }) => { + await visitPage(page, '/'); + await expect(page.locator('[data-section="trustbar"]')).toBeVisible(); + await expect(page.locator('[data-section="trustbar"]')).toContainText(/Microsoft tenant focused/i); + await expect(page.locator('[data-section="trustbar"]')).toContainText(/Evidence-oriented workflows/i); + await expect(page.locator('[data-section="trustbar"]')).toContainText(/Designed for audit review/i); + await expect(page.locator('[data-section="trustbar"]')).toContainText(/Operator-led governance/i); + + const pillars = page.locator('[data-section="feature-pillars"]'); + await expect(pillars).toBeVisible(); + + for (const capability of ['Backup', 'Restore', 'Drift Detection', 'Evidence', 'Audit Trail', 'Governance Reviews']) { + await expect(pillars.getByRole('heading', { name: capability, exact: true })).toBeVisible(); + } + + await expect(page.locator('body')).not.toContainText(/SOC 2|ISO\s?\d*|99\.9%|trusted by|customer logos/i); +}); + +test('homepage dashboard preview is static, responsive, and explains status without color alone', async ({ page, }) => { await visitPage(page, '/'); - await expectProductNearVisual(page, /change history, restore preview, and a review queue/i); - await expectHomepageHeroTrustSignals(page); - await expect(page.locator('[data-homepage-hero="true"] [data-hero-trust-signals]')).toContainText( - /tenant-scoped boundaries/i, - ); - await expect(page.locator('[data-homepage-hero="true"] [data-hero-trust-signals]')).toContainText( - /reviewable change history/i, - ); - await expect(page.locator('[data-homepage-hero="true"] [data-hero-trust-signals]')).toContainText( - /preview before restore/i, - ); + + const preview = page.locator('[data-dashboard-preview]').first(); + + await expect(preview).toBeVisible(); + await expect(preview).toContainText('Static demo preview'); + await expect(preview).toContainText('Demo values'); + await expect(preview).toContainText('92%'); + await expect(preview).toContainText('14'); + await expect(preview).toContainText('7'); + await expect(preview).toContainText('1,248'); + await expect(preview).toContainText('98%'); + + for (const panel of [ + 'Recent findings', + 'Drift timeline', + 'Backups and restores', + 'Governance reviews', + 'Evidence spotlight', + ]) { + await expect(preview.getByText(panel, { exact: true })).toBeVisible(); + } + + for (const status of ['Healthy', 'Review required', 'Critical', 'Review-ready']) { + await expect(preview.getByText(status, { exact: true }).first()).toBeVisible(); + } + + await expect(preview).not.toContainText(/real[- ]?time|streaming|live tenant feed|May 19|Updated 2m ago/i); }); -test('homepage shows dated progress signals before the final CTA', async ({ - page, -}) => { +test('homepage keeps the final CTA and launch-readiness sections in order', async ({ page }) => { await visitPage(page, '/'); - await expect(page.locator('[data-section="progress"]')).toBeVisible(); - await expectOnwardRouteReachable(page, ['/changelog']); -}); - -test('homepage routes into Product, Trust, Changelog, and Contact', async ({ - page, -}) => { - await visitPage(page, '/'); - await expectOnwardRouteReachable(page, ['/product', '/trust', '/changelog', '/contact']); + await expectHomepageSectionOrder(page, ['hero', 'trustbar', 'feature-pillars', 'cta']); + await expect(page.locator('[data-section="cta"]')).toContainText( + 'Build tenant governance on evidence, not assumptions.', + ); + await expectOnwardRouteReachable(page, ['/product', '/contact']); }); test.describe('homepage mobile', () => { test.use({ viewport: { width: 390, height: 844 } }); - test('homepage remains readable on narrow screens', async ({ page }) => { + test('homepage remains readable on narrow screens without body overflow', async ({ page }) => { await visitPage(page, '/'); await expectMobileReadability(page); - await expect(page.locator('[data-section="outcome"]')).toBeVisible(); - await expect(page.locator('[data-section="capability"]')).toBeVisible(); - await expect(page.locator('[data-section="trust"]')).toBeVisible(); + await expectNoBodyHorizontalOverflow(page); + await expect(page.locator('[data-section="trustbar"]')).toBeVisible(); + await expect(page.locator('[data-section="feature-pillars"]')).toBeVisible(); + await expect(page.locator('[data-dashboard-preview]').first()).toBeVisible(); }); test('homepage hero preserves meaning order and hero route intent on narrow screens', async ({ page }) => { @@ -146,8 +166,8 @@ test.describe('homepage mobile', () => { 'headline', 'supporting-copy', 'cta-pair', - 'product-near-visual', 'trust-subclaims', + 'product-near-visual', ]); await expectHomepageHeroVisibleOnMobile(page); await expectHomepageHeroRouteTargets(page, ['/contact', '/product']); diff --git a/apps/website/tests/smoke/smoke-helpers.ts b/apps/website/tests/smoke/smoke-helpers.ts index b9d96808..818d9b61 100644 --- a/apps/website/tests/smoke/smoke-helpers.ts +++ b/apps/website/tests/smoke/smoke-helpers.ts @@ -3,17 +3,32 @@ import { expect, type Page } from '@playwright/test'; export const coreRoutePaths = ['/', '/product', '/trust', '/changelog', '/contact', '/privacy', '/imprint'] as const; export const secondaryRoutePaths = ['/legal', '/terms', '/solutions', '/integrations'] as const; -export const primaryNavigationLabels = ['Product', 'Trust', 'Changelog', 'Contact'] as const; -export const hiddenPrimaryNavigationLabels = [ - 'Solutions', - 'Integrations', - 'Security & Trust', - 'Resources', - 'Articles', -] as const; +export const primaryNavigationLabels = ['Platform', 'Solutions', 'Resources', 'Pricing', 'Company'] as const; +export const hiddenPrimaryNavigationLabels = ['Product', 'Security & Trust', 'Articles'] as const; -export const footerLabels = ['Product', 'Changelog', 'Trust', 'Privacy', 'Imprint', 'Terms', 'Contact'] as const; -export const hiddenFooterLabels = ['Resources', 'Articles', 'Security & Trust', 'Contact / Demo'] as const; +export const footerGroupLabels = [ + 'Platform', + 'Solutions', + 'Resources', + 'Pricing', + 'Company', + 'Contact', + 'Legal', + 'Privacy', + 'Security', +] as const; +export const footerLabels = [ + 'Explore the platform', + 'Solutions', + 'Changelog', + 'Book a demo', + 'Contact', + 'Legal', + 'Imprint', + 'Privacy', + 'Trust', +] as const; +export const hiddenFooterLabels = ['Articles', 'Security & Trust', 'Contact / Demo'] as const; export async function visitPage(page: Page, path: string): Promise { await page.goto(path); @@ -39,26 +54,47 @@ export async function expectPageFamily(page: Page, family: 'content' | 'landing' export async function expectPrimaryNavigation(page: Page): Promise { const header = page.getByRole('banner'); + const expectedRoutes: Record<(typeof primaryNavigationLabels)[number], string> = { + Platform: '/product', + Solutions: '/solutions', + Resources: '/changelog', + Pricing: '/contact', + Company: '/contact', + }; for (const label of primaryNavigationLabels) { const link = header.getByRole('link', { name: label, exact: true }).first(); await expect(link).toBeVisible(); await expect(link).toHaveAttribute('data-nav-link'); + await expect(link).toHaveAttribute('href', expectedRoutes[label]); } for (const label of hiddenPrimaryNavigationLabels) { await expect(header.getByRole('link', { name: label, exact: true })).toHaveCount(0); } + + await expect(header.getByText('Sign in', { exact: true }).first()).toBeVisible(); + await expect(header.locator('[data-nav-state="deferred"]').filter({ hasText: 'Sign in' }).first()).toBeVisible(); + await expect(header.getByRole('link', { name: 'Book a demo', exact: true }).first()).toHaveAttribute( + 'href', + '/contact', + ); } export async function expectFooterLinks(page: Page): Promise { + const footer = page.getByRole('contentinfo'); + + for (const label of footerGroupLabels) { + await expect(footer.getByText(label, { exact: true }).first()).toBeVisible(); + } + for (const label of footerLabels) { - await expect(page.getByRole('contentinfo').getByRole('link', { name: label, exact: true })).toBeVisible(); + await expect(footer.getByRole('link', { name: label, exact: true }).first()).toBeVisible(); } for (const label of hiddenFooterLabels) { - await expect(page.getByRole('contentinfo').getByRole('link', { name: label, exact: true })).toHaveCount(0); + await expect(footer.getByRole('link', { name: label, exact: true })).toHaveCount(0); } } @@ -265,7 +301,7 @@ export async function expectNavigationVsCtaDifferentiation(page: Page): Promise< const header = page.getByRole('banner'); await expect(header.locator('[data-nav-link]').first()).toBeVisible(); - await expect(header.locator('[data-cta-weight="secondary"]').first()).toBeVisible(); + await expect(header.locator('[data-header-cta]').first()).toBeVisible(); } export async function expectHomepageSectionOrder(page: Page, sections: string[]): Promise { @@ -313,6 +349,17 @@ export async function expectMobileReadability(page: Page): Promise { await expect(page.getByRole('contentinfo')).toBeVisible(); } +export async function expectNoBodyHorizontalOverflow(page: Page): Promise { + const overflow = await page.evaluate(() => { + const documentElement = document.documentElement; + const body = document.body; + + return Math.max(documentElement.scrollWidth, body.scrollWidth) - documentElement.clientWidth; + }); + + expect(overflow, 'Page should not create body-level horizontal overflow').toBeLessThanOrEqual(1); +} + export async function expectOnwardRouteReachable(page: Page, routes: string[]): Promise { const main = page.getByRole('main'); diff --git a/apps/website/tests/smoke/visual-foundation-guardrails.spec.ts b/apps/website/tests/smoke/visual-foundation-guardrails.spec.ts index f569ba36..85bbc21b 100644 --- a/apps/website/tests/smoke/visual-foundation-guardrails.spec.ts +++ b/apps/website/tests/smoke/visual-foundation-guardrails.spec.ts @@ -13,13 +13,15 @@ test('representative pages route CTA, badge, surface, and input semantics throug page, }) => { await visitPage(page, '/'); - await expectShell(page, /control surface/i); + await expectShell(page, 'Evidence-first governance for Microsoft tenants.'); await expectPageFamily(page, 'landing'); await expectPrimaryNavigation(page); await expectNavigationVsCtaDifferentiation(page); - await expectCtaHierarchy(page, 'Request a working session', /product model/i); - await expect(page.locator('[data-interaction="button"]').filter({ hasText: 'Request a working session' }).first()).toBeVisible(); + await expectCtaHierarchy(page, 'Book a demo', 'Explore the platform'); + await expect(page.locator('[data-interaction="button"]').filter({ hasText: 'Book a demo' }).first()).toBeVisible(); await expect(page.locator('[data-badge-tone]').first()).toBeVisible(); + await expect(page.locator('[data-shell-surface="header"]').first()).toHaveAttribute('data-visual-tone', 'dark'); + await expect(page.locator('[data-section="feature-pillars"] [data-surface="tenantial-pillar"]').first()).toBeVisible(); await visitPage(page, '/trust'); await expectShell(page, /trust posture|trust/i); diff --git a/specs/400-tenantial-homepage-visual-rebuild/checklists/requirements.md b/specs/400-tenantial-homepage-visual-rebuild/checklists/requirements.md new file mode 100644 index 00000000..0fcec2e8 --- /dev/null +++ b/specs/400-tenantial-homepage-visual-rebuild/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Tenantial Homepage Visual Rebuild + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-17 +**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 iteration 1: Passed. The spec contains no clarification markers or template placeholders, keeps functional requirements focused on public homepage outcomes, and confines repo-specific validation language to the required testing impact section. +- Planning review correction: Approval class is exactly one class (`Core Enterprise`) per SPEC-GATE-001. +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/400-tenantial-homepage-visual-rebuild/contracts/public-homepage.yaml b/specs/400-tenantial-homepage-visual-rebuild/contracts/public-homepage.yaml new file mode 100644 index 00000000..d5fdf1cb --- /dev/null +++ b/specs/400-tenantial-homepage-visual-rebuild/contracts/public-homepage.yaml @@ -0,0 +1,110 @@ +openapi: 3.1.0 +info: + title: Tenantial Public Homepage Route Contract + version: "400.0.0" + summary: Static route expectations for the Tenantial homepage rebuild. + description: > + This contract documents public static website route behavior for Spec 400. + It does not introduce a backend API, authentication flow, database contract, + Microsoft Graph integration, or platform runtime dependency. +servers: + - url: http://127.0.0.1:4321 + description: Local Astro website development server +paths: + /: + get: + summary: Render the Tenantial public homepage + operationId: renderTenantialHomepage + tags: + - public-website + responses: + "200": + description: Static HTML homepage for Tenantial + content: + text/html: + schema: + type: string + examples: + homepage: + summary: Required first-read content + value: "Tenantial - Evidence-first governance for Microsoft tenantsEvidence-first governance for Microsoft tenants." + x-route-kind: static-html + x-required-visible-content: + brand: Tenantial + headline: Evidence-first governance for Microsoft tenants. + primaryCta: Book a demo + secondaryCta: Explore the platform + requiredCapabilities: + - Backup + - Restore + - Drift Detection + - Evidence + - Audit Trail + - Governance Reviews + dashboardMetrics: + overallPosture: "92%" + findings: "14" + driftDetected: "7" + evidenceItems: "1,248" + backupStatus: "98%" + x-required-sections: + - Header + - Hero + - DashboardPreview + - TrustBar + - FeaturePillars + - CTASection + - Footer + x-forbidden-visible-or-metadata-content: + - AstroDeck + - Open Source + - MIT Licensed + - TenantCTRL + - TenantPilot + - TenantAtlas + x-trust-claim-boundary: + allowed: + - Microsoft tenant focused + - Evidence-oriented workflows + - Designed for audit review workflows + - Operator-led governance + forbidden-unless-verified: + - Real customer logos + - SOC 2 certification + - ISO certification + - 99.9% uptime + - Trusted by named real companies + x-accessibility-contract: + primaryHeadings: 1 + focusVisible: true + keyboardReachableNavigation: true + nonColorOnlyStatus: true + mobileBodyHorizontalOverflow: false + /contact: + get: + summary: Intentional destination for Book a demo + operationId: renderContactDestination + tags: + - public-website + responses: + "200": + description: Existing public contact route used as the demo destination + content: + text/html: + schema: + type: string + x-navigation-role: primary-homepage-cta-target + /product: + get: + summary: Intentional destination for Explore the platform + operationId: renderProductDestination + tags: + - public-website + responses: + "200": + description: Existing public product route used as the platform exploration destination + content: + text/html: + schema: + type: string + x-navigation-role: secondary-homepage-cta-target diff --git a/specs/400-tenantial-homepage-visual-rebuild/data-model.md b/specs/400-tenantial-homepage-visual-rebuild/data-model.md new file mode 100644 index 00000000..b298bae4 --- /dev/null +++ b/specs/400-tenantial-homepage-visual-rebuild/data-model.md @@ -0,0 +1,216 @@ +# Data Model: Tenantial Homepage Visual Rebuild + +This feature introduces no persisted database model, no runtime API schema, and no platform data contract. The models below describe static public website content that supports implementation and test planning. + +## Entity: Homepage Message + +**Purpose**: Defines the first-read Tenantial brand, product category, promise, and CTA hierarchy for `/`. + +**Fields**: + +- `brandName`: Must be `Tenantial`. +- `eyebrow`: Must communicate "GOVERNANCE THAT EARNS TRUST" or equivalent. +- `headline`: Must be "Evidence-first governance for Microsoft tenants." +- `supportingCopy`: Must mention backup, restore, drift detection, snapshot-backed audit context, evidence, and structured reviews. +- `primaryCta`: CTA with label `Book a demo` and an intentional destination. +- `secondaryCta`: CTA with label `Explore the platform` and an intentional destination. +- `trustBullets`: Up to three short trust cues. + +**Relationships**: + +- Owns one primary CTA and one secondary CTA. +- Appears before Dashboard Preview and supporting homepage sections. + +**Validation Rules**: + +- Exactly one primary page heading must be present. +- Public visible copy must not include old/template brand names. +- CTA hierarchy must remain primary `Book a demo`, secondary `Explore the platform`. + +**State Transitions**: None. + +## Entity: Header Navigation Item + +**Purpose**: Defines one visible public header label and its behavior. + +**Fields**: + +- `label`: One of Platform, Solutions, Resources, Pricing, Company, Sign in, Book a demo. +- `destination`: Existing route, known external URL, or no-link/de-emphasized text for unavailable sign-in. +- `priority`: Primary CTA, secondary utility, or navigation. +- `behavior`: Link, CTA, or inert/de-emphasized label. + +**Relationships**: + +- Header owns multiple navigation items. +- CTA items point visitors toward Contact or Product routes. + +**Validation Rules**: + +- Must not point to dead template routes. +- `Book a demo` must remain the primary visible action. +- `Sign in` must not imply implemented auth unless a real sign-in destination exists. + +**State Transitions**: None. + +## Entity: Static Dashboard Preview + +**Purpose**: Communicates product-near governance posture using static demo values. + +**Fields**: + +- `overallPosture`: Static demo value `92%`. +- `findings`: Static demo value `14`. +- `driftDetected`: Static demo value `7`. +- `evidenceItems`: Static demo value `1,248`. +- `backupStatus`: Static demo value `98%`. +- `panels`: Recent findings, drift timeline, backups and restores, reviews, evidence spotlight. +- `statusLabels`: Text labels that explain status meaning without relying on color only. + +**Relationships**: + +- Contains Dashboard Metrics and Dashboard Panels. +- Appears with or immediately after the Hero. + +**Validation Rules**: + +- Must not depend on backend/API/auth/tenant/platform data. +- Must not claim to be live or realtime. +- Must remain readable on desktop and usable on mobile without body-level horizontal overflow. +- Status meaning must be available through text, not only color. + +**State Transitions**: None. + +## Entity: Dashboard Metric + +**Purpose**: A single static value in the Dashboard Preview. + +**Fields**: + +- `label`: Human-readable metric name. +- `value`: Static demo value. +- `tone`: Healthy, warning, critical, evidence, or neutral. +- `description`: Short text explaining the metric. + +**Relationships**: + +- Belongs to Static Dashboard Preview. + +**Validation Rules**: + +- Tone must not be the only status signal. +- Value must be static and clearly demo-like. + +**State Transitions**: None. + +## Entity: Trust Statement + +**Purpose**: Provides credibility without fake proof. + +**Fields**: + +- `title`: Short credibility statement. +- `description`: One-sentence explanation. +- `claimType`: Product focus, workflow discipline, audit review workflow design, or operator governance. + +**Relationships**: + +- Appears inside Trust Bar. + +**Validation Rules**: + +- Must not use real customer logos unless verified. +- Must not claim SOC 2, ISO, 99.9% uptime, or named customer trust unless verified. +- Must not overstate security/compliance posture. + +**State Transitions**: None. + +## Entity: Feature Pillar + +**Purpose**: Describes one required Tenantial homepage capability. + +**Fields**: + +- `title`: Backup, Restore, Drift Detection, Evidence, Audit Trail, or Governance Reviews. +- `description`: Concise capability explanation. +- `iconLabel`: Meaningful accessible label or decorative status. +- `proofBoundary`: Copy note ensuring unsupported claims are avoided. + +**Relationships**: + +- Feature Pillars section contains exactly the required six capability pillars unless implementation explicitly keeps four to six cards while preserving all required capabilities. + +**Validation Rules**: + +- Required capability titles must be visible. +- Copy must be short and product-specific. +- Unsupported security/compliance promises are forbidden. + +**State Transitions**: None. + +## Entity: CTA Section + +**Purpose**: Repeats the homepage's primary next step near the bottom of the page. + +**Fields**: + +- `headline`: "Build tenant governance on evidence, not assumptions." or equivalent. +- `primaryCta`: `Book a demo`. +- `secondaryCta`: `Explore the platform`. + +**Relationships**: + +- Mirrors Homepage Message CTA hierarchy. + +**Validation Rules**: + +- CTA labels and destinations must match actual behavior. +- CTA text must not clip on mobile. + +**State Transitions**: None. + +## Entity: Footer Link Group + +**Purpose**: Provides public footer navigation without template residue. + +**Fields**: + +- `groupTitle`: Platform, Solutions, Resources, Pricing, Company, Contact, Legal, Privacy, or Security. +- `items`: Intentional links or route aliases to existing public pages. +- `brandName`: Tenantial. + +**Relationships**: + +- Footer owns multiple Footer Link Groups. + +**Validation Rules**: + +- Footer must not contain AstroDeck/template content or old public brand names. +- Legal/privacy/security links must point to existing routes where available. +- No fake social proof. + +**State Transitions**: None. + +## Entity: SEO Metadata + +**Purpose**: Defines public search/social metadata for the homepage. + +**Fields**: + +- `title`: `Tenantial - Evidence-first governance for Microsoft tenants`. +- `description`: Tenantial-specific summary of backup, restore, drift detection, audit trails, evidence, and structured reviews. +- `ogTitle`: Evidence-first governance for Microsoft tenants. +- `ogDescription`: Evidence-oriented governance positioning without unsupported certification, uptime, customer, or security guarantees. +- `canonicalPath`: `/`. + +**Relationships**: + +- Applies to Homepage Message and public route `/`. + +**Validation Rules**: + +- Must not include AstroDeck/template titles. +- Must not include old public brand names. +- Must not include unverified customer, certification, or uptime claims. + +**State Transitions**: None. diff --git a/specs/400-tenantial-homepage-visual-rebuild/plan.md b/specs/400-tenantial-homepage-visual-rebuild/plan.md new file mode 100644 index 00000000..987cab78 --- /dev/null +++ b/specs/400-tenantial-homepage-visual-rebuild/plan.md @@ -0,0 +1,201 @@ +# Implementation Plan: Tenantial Homepage Visual Rebuild + +**Branch**: `400-tenantial-homepage-visual-rebuild` | **Date**: 2026-05-17 | **Spec**: [spec.md](/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/spec.md) +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/spec.md` + +**Status**: Implementation complete in this branch. This plan now records the bounded Spec 400 implementation slice and its validation path. + +## Summary + +Rebuild the public `/` homepage in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website` so Tenantial is presented as a premium dark enterprise SaaS product for evidence-first Microsoft tenant governance. The implementation approach is a static Astro website slice: update homepage content, public shell/navigation/footer, website-local design tokens, a static product-near dashboard preview, SEO metadata, narrow public-page brand consistency, and Playwright smoke expectations without adding platform runtime, backend data, auth, database, Filament, Livewire, or Microsoft Graph coupling. + +## Technical Context + +**Language/Version**: TypeScript 5.9, Astro 6 static components, HTML, CSS +**Primary Dependencies**: Astro 6.0.0, Tailwind CSS 4.2.2 through CSS-first `@theme` and `@tailwindcss/vite`, `astro-icon`, `@iconify-json/lucide`, Playwright 1.59.1 +**Storage**: N/A - static public website content only; no database or persisted runtime data +**Testing**: Playwright smoke tests under `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke` plus Astro static build +**Validation Lanes**: browser +**Target Platform**: Static public website generated by Astro and served by the website deployment container +**Project Type**: Web frontend static site in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website` +**Performance Goals**: Static homepage renders without heavy third-party scripts, body-level horizontal overflow, clipped CTA text, or live-data dependencies; desktop and mobile first-read content remains usable and readable +**Constraints**: No platform/auth/API/database/Graph coupling; no fake customer proof, certifications, or uptime claims; Tailwind v4 conventions only; use existing workspace script names and `WEBSITE_PORT` behavior; dark page must maintain accessible focus, contrast, and non-color-only status meaning; dark visual direction must use the Spec 400 Obsidian/Ivory/Mint baseline: near-black page background, ivory primary text, muted warm gray secondary text, mint primary accent, amber warning, coral critical, violet evidence/review accents, and WCAG-readable contrast for text and controls +**Scale/Scope**: One public route (`/`) plus shared public shell elements used by that route: header, footer, SEO metadata, website-local styles/tokens, static homepage sections, homepage smoke tests, and minimal brand/SEO cleanup on existing public pages reachable from the global shell + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: No operator-facing surface change. +- **Native vs custom classification summary**: N/A for Filament/admin surfaces. Public website components may be customized within `apps/website`. +- **Shared-family relevance**: None for operator-facing shared families. +- **State layers in scope**: Public website page and shell state only; no admin shell, detail state, URL-query state, tenant state, or operation state. +- **Audience modes in scope**: Public visitor, MSP buyer, enterprise IT buyer, security/compliance reviewer. +- **Decision/diagnostic/raw hierarchy plan**: Public marketing first-read content only; no operator diagnostics or support/raw evidence surfaces. +- **Raw/support gating plan**: N/A. +- **One-primary-action / duplicate-truth control**: Homepage keeps "Book a demo" as the primary CTA and "Explore the platform" as secondary; repeated CTA sections preserve this hierarchy. +- **Handling modes by drift class or surface**: N/A. +- **Repository-signal treatment**: Report-only for website smoke review; no guardrail exception. +- **Special surface test profiles**: N/A. +- **Required tests or manual smoke**: Homepage Playwright smoke coverage, metadata/template-residue assertions, mobile overflow check, desktop/mobile screenshot review. +- **Exception path and spread control**: None. +- **Active feature PR close-out entry**: Smoke Coverage. + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: No for operator-facing systems; yes only in the public website sense that header/footer/site metadata may be updated for Tenantial. +- **Systems touched**: Public website app under `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website`; homepage page, content, layout navigation/footer, styles, static visual component, existing public-page brand copy, and smoke tests. +- **Shared abstractions reused**: Existing website shell, layout, primitive/content/section components where they fit; existing Playwright smoke helper style. +- **New abstraction introduced? why?**: No runtime or domain abstraction. A page-local static dashboard preview component is acceptable as presentation, not shared product truth. +- **Why the existing abstraction was sufficient or insufficient**: Existing Astro/Tailwind/Playwright website structure is sufficient. Existing copy, light palette, TenantAtlas brand, and product visual direction are insufficient for the Tenantial dark enterprise homepage. +- **Bounded deviation / spread control**: Homepage-specific static preview and dark visual treatment stay in `apps/website`; no platform or admin design system changes. Existing internal workspace names such as `@tenantatlas/website` remain unchanged. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: No. +- **Central contract reused**: N/A. +- **Delegated UX behaviors**: N/A. +- **Surface-owned behavior kept local**: N/A. +- **Queued DB-notification policy**: N/A. +- **Terminal notification path**: N/A. +- **Exception path**: None. + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: No. +- **Provider-owned seams**: N/A. +- **Platform-core seams**: N/A. +- **Neutral platform terms / contracts preserved**: Existing runtime contracts remain unchanged. +- **Retained provider-specific semantics and why**: Marketing language may say "Microsoft tenants" because the public product positioning targets that environment. It must not change provider contracts, identifiers, compare semantics, governed-subject taxonomy, or runtime naming. +- **Bounded extraction or follow-up path**: None. + +## Constitution Check + +### Pre-Design Gate + +PASS. No unresolved gate failures. + +- **Spec Candidate Gate**: Passed in spec with exactly one approval class (`Core Enterprise`), approval score 11/12, and decision `approve`. +- **Inventory / snapshots / Graph / deterministic capabilities**: N/A. No platform inventory, snapshots, Graph calls, or capability resolver behavior. +- **Read/write separation**: PASS. Public static website change only; no write/change operation. +- **RBAC / workspace / tenant isolation**: N/A. Public homepage introduces no authorization, tenant route, workspace context, global search, or destructive action. +- **OperationRun / Ops-UX**: N/A. No queued, scheduled, remote, long-running, or operation-link behavior. +- **Data minimization**: PASS. Static demo preview data only; no secrets, tenant data, or live payloads. +- **Test governance**: PASS. Browser lane is explicit and scoped to homepage route/build behavior. +- **Proportionality / bloat**: PASS. No persisted entity, enum/status family, resolver, registry, interface, DTO layer, or cross-domain UI framework. +- **Shared pattern first**: PASS. Reuse website shell/primitives/test helpers where practical; no operator-facing shared path touched. +- **Provider boundary**: PASS. Microsoft tenant wording remains marketing positioning and does not enter platform-core contracts. +- **Filament / Livewire**: N/A. No Filament v5, Livewire v4, provider registration, global search, destructive action, or Filament asset behavior is touched. + +### Post-Design Gate + +PASS. Phase 0 and Phase 1 artifacts keep the feature static, bounded, and website-local. + +- `research.md` resolves technical choices without adding framework or runtime coupling. +- `data-model.md` models static content only and explicitly avoids persisted data. +- `contracts/public-homepage.yaml` documents public route expectations and does not define a backend API. +- `quickstart.md` uses existing website build/test commands and does not introduce Sail, Laravel, Filament, database, or platform setup. +- No `NEEDS CLARIFICATION` markers remain. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Browser for public homepage route, content, responsive behavior, metadata, and visual smoke. +- **Affected validation lanes**: browser. +- **Why this lane mix is the narrowest sufficient proof**: The behavior is user-visible static website rendering. Browser smoke tests and build catch route, content, responsive, and metadata regressions without database or platform setup. +- **Narrowest proving command(s)**: `cd /Users/ahmeddarrazi/Documents/projects/wt-website && corepack pnpm build:website`; `cd /Users/ahmeddarrazi/Documents/projects/wt-website && WEBSITE_PORT=4321 corepack pnpm --filter @tenantatlas/website test:smoke` +- **Fixture / helper / factory / seed / context cost risks**: None. No DB, session, workspace, tenant, provider, member, or seed setup. +- **Expensive defaults or shared helper growth introduced?**: No. Any smoke helper additions must remain website-only. +- **Heavy-family additions, promotions, or visibility changes**: Browser coverage is deliberate and homepage-scoped; no heavy-governance family. +- **Surface-class relief / special coverage rule**: N/A. +- **Closing validation and reviewer handoff**: Reviewers should verify no platform setup appears in website tests, no unverified trust claims appear, no old brand/template residue remains on the homepage or globally reachable public shell, and mobile body overflow is absent. +- **Budget / baseline / trend follow-up**: None expected. +- **Review-stop questions**: Stop if smoke tests require platform state, if the dashboard preview depends on live data, if unsupported trust claims appear, if hidden horizontal overflow remains, or if navigation points to stale template routes. +- **Escalation path**: document-in-feature. +- **Active feature PR close-out entry**: Smoke Coverage. +- **Why no dedicated follow-up spec is needed**: This is a contained homepage browser smoke update. A separate follow-up is only needed if the visual system expands into a cross-page public website framework. + +## Project Structure + +### Documentation (this feature) + +```text +/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── public-homepage.yaml +└── tasks.md # Created later by /speckit.tasks +``` + +### Source Code (repository root) + +```text +/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/ +├── src/ +│ ├── pages/ +│ │ └── index.astro +│ ├── content/pages/ +│ │ └── home.ts +│ ├── lib/ +│ │ ├── site.ts +│ │ └── seo.ts +│ ├── components/ +│ │ ├── layout/ +│ │ │ ├── Navbar.astro +│ │ │ ├── PageShell.astro +│ │ │ └── Footer.astro +│ │ ├── content/ +│ │ │ └── DashboardPreview.astro # expected new or renamed static preview +│ │ └── sections/ +│ │ ├── FeaturePillars.astro # expected new or adapted homepage section +│ │ ├── TrustBar.astro # expected new or adapted homepage section +│ │ └── CTASection.astro +│ ├── styles/ +│ │ ├── tokens.css +│ │ └── global.css +│ └── types/ +│ └── site.ts + ├── public/ +│ └── favicon.svg +└── tests/smoke/ + ├── changelog-core-ia.spec.ts + ├── home-product.spec.ts + ├── smoke-helpers.ts + └── visual-foundation-guardrails.spec.ts +``` + +**Structure Decision**: Use the existing Astro website app and existing smoke-test family. The implementation should prefer adapting existing content/layout/primitives before adding new components. Any new dashboard or section component must remain website-local presentation and must not become a shared platform/admin abstraction. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| None | N/A | N/A | + +## Proportionality Review + +- **Current operator problem**: Prospective buyers and stakeholders cannot currently understand Tenantial's Microsoft tenant governance value from the public homepage. +- **Existing structure is insufficient because**: Current homepage content, brand naming, light visual direction, and product visual do not match the premium dark Tenantial direction or required evidence-first story. +- **Narrowest correct implementation**: One static homepage slice plus the public shell/styling/smoke checks needed for that page. +- **Ownership cost created**: Website-local homepage content, static preview data, navigation mapping, public SEO metadata, and smoke assertions must be maintained. +- **Alternative intentionally rejected**: Full multi-page website rebuild or platform-coupled product data preview. +- **Release truth**: Current public website direction only. + +## Phase 0: Research Output + +Completed in [research.md](/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/research.md). All planning unknowns are resolved. + +## Phase 1: Design And Contracts Output + +Completed artifacts: + +- [data-model.md](/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/data-model.md) +- [contracts/public-homepage.yaml](/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/contracts/public-homepage.yaml) +- [quickstart.md](/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/quickstart.md) + +Agent context update command: + +```bash +cd /Users/ahmeddarrazi/Documents/projects/wt-website +.specify/scripts/bash/update-agent-context.sh codex +``` diff --git a/specs/400-tenantial-homepage-visual-rebuild/quickstart.md b/specs/400-tenantial-homepage-visual-rebuild/quickstart.md new file mode 100644 index 00000000..ce10d57d --- /dev/null +++ b/specs/400-tenantial-homepage-visual-rebuild/quickstart.md @@ -0,0 +1,73 @@ +# Quickstart: Tenantial Homepage Visual Rebuild + +## Prerequisites + +- Work from `/Users/ahmeddarrazi/Documents/projects/wt-website`. +- Use Node.js 20+ with Corepack and pnpm, matching the repo root `packageManager`. +- This feature does not require Sail, Laravel, Filament, a database, Microsoft Graph credentials, or platform auth. + +## Install Dependencies + +```bash +cd /Users/ahmeddarrazi/Documents/projects/wt-website +corepack pnpm install +``` + +## Run The Website Locally + +```bash +cd /Users/ahmeddarrazi/Documents/projects/wt-website +WEBSITE_PORT=4321 corepack pnpm dev:website +``` + +Open `http://127.0.0.1:4321/`. + +## Build Validation + +```bash +cd /Users/ahmeddarrazi/Documents/projects/wt-website +corepack pnpm build:website +``` + +Expected result: Astro builds the static website without requiring platform services. + +## Browser Smoke Validation + +```bash +cd /Users/ahmeddarrazi/Documents/projects/wt-website +WEBSITE_PORT=4321 corepack pnpm --filter @tenantatlas/website test:smoke +``` + +Expected proof: + +- `/` renders the Tenantial homepage. +- The headline "Evidence-first governance for Microsoft tenants." is visible. +- `Book a demo` and `Explore the platform` are visible with the correct hierarchy. +- Static dashboard preview content is visible. +- Backup, Restore, Drift Detection, Evidence, Audit Trail, and Governance Reviews are visible. +- Old/template public copy is absent from visible homepage content and metadata. +- Existing public pages reachable from the global shell use Tenantial visible brand copy and SEO metadata. +- Mobile viewport has no body-level horizontal overflow. + +## Manual Review + +Use desktop and mobile browser review after implementation. + +Review points: + +- Tenantial is the only public brand visible on the homepage. +- No AstroDeck, Open Source, MIT Licensed, TenantCTRL, TenantPilot, or TenantAtlas homepage residue remains. +- No unverified customer logos, certification claims, uptime claims, or fake social proof appear. +- Dashboard preview labels use static/sample/demo wording and do not show future-dated timestamps. +- Header labels and CTA destinations are intentional. +- Dashboard preview is static and does not imply live/realtime data. +- Text does not clip, overlap, or create mobile body overflow. +- Focus states are visible and mobile navigation is keyboard usable. + +## Out Of Scope For This Feature + +- Do not add demo-booking backend behavior. +- Do not add sign-in/auth behavior. +- Do not add platform API calls. +- Do not add Laravel, Filament, Livewire, Microsoft Graph, database, queue, or OperationRun behavior. +- Do not rename root packages or existing workspace scripts. diff --git a/specs/400-tenantial-homepage-visual-rebuild/research.md b/specs/400-tenantial-homepage-visual-rebuild/research.md new file mode 100644 index 00000000..02156e31 --- /dev/null +++ b/specs/400-tenantial-homepage-visual-rebuild/research.md @@ -0,0 +1,94 @@ +# Phase 0 Research: Tenantial Homepage Visual Rebuild + +## Decision: Keep the implementation inside the existing Astro website app + +**Decision**: Use `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website` as the only runtime surface for this feature. + +**Rationale**: The feature is a public static homepage rebuild. The repo already has a standalone Astro website app with static output, a public route structure, shared layout components, SEO helpers, Tailwind v4 styling, and Playwright smoke tests. This matches the feature without platform runtime setup. + +**Alternatives considered**: + +- Add platform/Laravel/Filament integration: rejected because the spec forbids platform coupling and this is public marketing content. +- Create a separate website app: rejected because an Astro website app already exists. +- Use a CMS/content collection strategy now: rejected because the spec explicitly excludes CMS and full content architecture. + +## Decision: Update website-local brand metadata and public brand copy from TenantAtlas to Tenantial + +**Decision**: Treat Tenantial as the public brand for the homepage and update homepage-visible copy, SEO metadata, header/footer labels, smoke expectations, and narrow brand/SEO copy on existing public pages reachable from the global shell accordingly. + +**Rationale**: The current website content and `siteMetadata` still identified the public brand as TenantAtlas. The spec requires Tenantial to be the only visible public brand on the homepage and requires old/template brand residue to be absent. Because the updated header/footer route visitors to existing public pages, those pages also need minimal brand consistency cleanup so the website does not expose mixed public-brand SEO/copy. + +**Alternatives considered**: + +- Leave shared metadata as TenantAtlas and override only the homepage: rejected because header/footer/OG metadata would still leak the old public brand on the homepage. +- Rename root packages and internal workspace names: rejected because the spec requires internal package names and workspace scripts to remain unchanged. +- Redesign secondary public pages: rejected because Spec 400 is a homepage implementation slice; secondary pages receive only narrow brand/SEO consistency cleanup. + +## Decision: Use Tailwind CSS v4 CSS-first tokens and existing global styles + +**Decision**: Implement the premium dark visual direction by adapting `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/styles/tokens.css` and `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/styles/global.css`, using Tailwind v4-compatible CSS-first `@theme` tokens and existing utility patterns. + +**Rationale**: The app already imports Tailwind with `@import "tailwindcss"` and defines tokens with `@theme`. This matches project and Tailwind v4 conventions and avoids deprecated Tailwind v3 utilities. + +**Alternatives considered**: + +- Add `tailwind.config.js`: rejected because Tailwind v4 in this project is CSS-first. +- Add a one-off inline style block for all homepage visuals: rejected because homepage, header, footer, and CTA surfaces need consistent website-local tokens. +- Add a heavy visual library: rejected because the spec requires no heavy third-party scripts for static visuals. + +## Decision: Build the dashboard preview as static semantic HTML/CSS, not a raster screenshot or live data + +**Decision**: Implement a static product-near dashboard preview with fixed demo values and semantic labels. + +**Rationale**: The spec requires the preview to be sharp, responsive, and clearly static. It must not imply live product data or depend on backend/API/auth/tenant data. HTML/CSS keeps the preview accessible, inspectable, responsive, and testable. + +**Alternatives considered**: + +- Raster screenshot: rejected because the spec asks for HTML/CSS/SVG and because text/status assertions would be weaker. +- Live dashboard data: rejected because the spec forbids backend/API/platform coupling. +- Interactive preview controls: rejected because they could imply functional product behavior that is not implemented. + +## Decision: Map homepage navigation to existing intentional routes and de-emphasize sign-in + +**Decision**: Use existing routes for visible CTAs where possible: `Book a demo` -> `/contact`, `Explore the platform` -> `/product`, `Platform` -> `/product`, and `Solutions` -> `/solutions`. `Resources`, `Pricing`, `Company`, and `Sign in` currently have no implemented same-name routes; they must be intentionally handled through existing routes, disabled/de-emphasized treatment, or omitted from functional link behavior instead of silently pointing at dead routes. `Sign in` must be text-only/de-emphasized or point only to a known valid platform URL if one is confirmed during implementation. + +**Rationale**: The current website has `/product`, `/solutions`, `/contact`, `/trust`, `/changelog`, `/integrations`, and legal routes, but no `/pricing`, `/company`, `/resources`, or known sign-in route. `/resources` is currently gated off, and existing tests expect it to stay hidden until substantive material exists. The spec requires intentional links and forbids silent dead template routes or implying implemented auth. + +**Alternatives considered**: + +- Create secondary pages now: rejected because the spec excludes a full multi-page website build. +- Link to missing future routes: rejected because the spec requires intentional, non-dead routes. +- Hide all unavailable labels: rejected because the spec expects the desktop header to carry the named navigation labels. + +## Decision: Use Playwright smoke coverage plus static build as validation + +**Decision**: Validate with `corepack pnpm build:website` and the existing website Playwright smoke lane. + +**Rationale**: This change is user-visible static website behavior. Browser smoke tests can verify route rendering, brand/copy, CTA hierarchy, forbidden copy, responsive overflow, metadata, and accessibility-relevant structure without database or platform setup. + +**Alternatives considered**: + +- Laravel/Pest tests: rejected because no Laravel/platform behavior is changed. +- Unit tests for every presentation component: rejected because this would over-test thin static presentation and add maintenance without proving user value. +- Visual regression infrastructure: deferred because this spec only requires browser screenshot review, not a permanent screenshot regression system. + +## Decision: No backend API contract is introduced + +**Decision**: The contract artifact documents the public static route expectations instead of defining a backend API. + +**Rationale**: The homepage has user actions such as opening `/` and following links, but no data submission, backend mutation, authentication, or API response. A route contract gives reviewers a concrete acceptance surface while preserving the spec's no-backend boundary. + +**Alternatives considered**: + +- Generate REST endpoints for demo booking or sign-in: rejected because those flows are out of scope. +- Skip contracts entirely: rejected because the `/speckit.plan` workflow requires a contracts artifact. + +## Decision: No unresolved clarifications remain + +**Decision**: All open implementation choices have reasonable defaults from the spec. + +**Rationale**: The feature has defaults for brand, CTA destinations, logo availability, customer proof, static preview content, and secondary pages. None require user clarification before planning. + +**Alternatives considered**: + +- Ask whether `/pricing`, `/company`, or `/resources` pages should be built: rejected because the spec's non-goals and assumptions exclude secondary pages for this slice. diff --git a/specs/400-tenantial-homepage-visual-rebuild/spec.md b/specs/400-tenantial-homepage-visual-rebuild/spec.md new file mode 100644 index 00000000..fcc5e214 --- /dev/null +++ b/specs/400-tenantial-homepage-visual-rebuild/spec.md @@ -0,0 +1,260 @@ +# Feature Specification: Tenantial Homepage Visual Rebuild + +**Feature Branch**: `400-tenantial-homepage-visual-rebuild` +**Created**: 2026-05-17 +**Status**: Implemented (ready to merge) +**Input**: User description: "Rebuild the public website homepage for Tenantial to match a premium dark enterprise SaaS mockup direction, positioning Tenantial as evidence-first governance for Microsoft tenants." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: The public homepage still carries generic SaaS/template signals and does not clearly position Tenantial as a serious Microsoft tenant governance product. +- **Today's failure**: Visitors can leave without understanding that Tenantial focuses on backup, restore, drift detection, evidence, audit trails, and structured governance reviews for Microsoft tenants. +- **User-visible improvement**: The homepage immediately communicates Tenantial's evidence-first governance value, uses a distinct premium dark identity, and shows a product-near static dashboard preview without implying live data. +- **Smallest enterprise-capable version**: Rebuild only the public homepage and the shared public shell needed by that page: header, hero, static dashboard preview, trust bar, feature pillars, call to action, footer, SEO metadata, homepage smoke expectations, and minimal public-page brand consistency where globally reachable pages would otherwise mix Tenantial and TenantAtlas. +- **Explicit non-goals**: No platform UI, no Filament/admin changes, no authentication flow, no CMS, no backend data, no demo-booking backend, no database changes, no Microsoft Graph integration, and no full multi-page website build. +- **Permanent complexity imported**: Public homepage brand direction, static marketing content, a static product-preview concept, homepage smoke coverage, and brand/SEO cleanup expectations. +- **Why now**: The project needs one credible Tenantial homepage direction before expanding into platform, pricing, trust, resources, or company pages. +- **Why not local**: Page-local copy changes alone would leave brand hierarchy, navigation intent, trust boundaries, product-preview language, and template cleanup inconsistent. +- **Approval class**: Core Enterprise +- **Red flags triggered**: None. The work creates no persisted truth, no domain state, no runtime abstraction, and no cross-domain UI framework. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 2 | Wiederverwendung: 1 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: Public website homepage. This is outside workspace, tenant, and canonical admin scopes. +- **Primary Routes**: `/` +- **Data Ownership**: Static public website content and static marketing preview data only. No workspace-owned, tenant-owned, platform runtime, or persisted application data is introduced. +- **RBAC**: None. The homepage is public and introduces no authorization behavior. + +## Relationship To Existing Website Specs + +- Spec 223 remains the historical AstroDeck reset/rebuild frame. +- Spec 214 remains useful background for website visual discipline. +- Spec 215 remains useful for public information architecture principles. +- Spec 217 and Spec 218 remain background for homepage structure and hero discipline. +- Spec 400 is the concrete homepage implementation slice for the Tenantial mockup direction. +- Spec 400 owns the homepage implementation direction where the Tenantial mockup direction is more specific than the older AstroDeck-era homepage specs. +- Spec 400 permits website-local homepage presentation components for this slice only. It does not create a reusable public-site framework and does not broaden the AstroDeck reset into platform/admin code. +- No `specs/227-*` artifact is present in the current checkout. No phantom Spec 227 is created here; Spec 400 replaces the previously discussed homepage/visual-foundation path for the homepage only. + +## 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`)* + +N/A - no shared operator interaction family touched. This feature changes a public marketing homepage, not admin notifications, operator status messaging, operation links, dashboard signals, alerts, report viewers, or other shared operator interaction contracts. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +N/A - no OperationRun start, completion, deduplication, resume, block, or link semantics touched. + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +N/A - no shared provider/platform boundary touched. The homepage may describe Microsoft tenant governance at a marketing level, but it does not change platform-core contracts, provider seams, identity scope, compare strategy, governed-subject taxonomy, or runtime vocabulary. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +N/A - no operator-facing surface change. This feature changes a public marketing surface only and does not affect Filament/admin pages, tenant-scoped operator workflows, shared operator components, action surfaces, or governance decision surfaces. + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: Yes, limited to public homepage brand/content direction. No product runtime or tenant-governance truth is introduced. +- **New persisted entity/table/artifact?**: No. +- **New abstraction?**: No runtime abstraction. Any page components are website-local presentation pieces for this homepage slice. +- **New enum/state/reason family?**: No. +- **New cross-domain UI framework/taxonomy?**: No. +- **Current operator problem**: Prospective buyers and stakeholders cannot yet infer Tenantial's Microsoft tenant governance value from the public homepage. +- **Existing structure is insufficient because**: The current website direction still contains template residue and does not provide a focused brand, trust, and evidence story for Tenantial. +- **Narrowest correct implementation**: One homepage slice with static content, static preview data, clear CTA hierarchy, trust boundaries, accessibility/responsive expectations, SEO cleanup, and smoke coverage. +- **Ownership cost**: Homepage copy, static preview content, public navigation labels, and brand/SEO expectations must be maintained as later website pages are added. +- **Alternative intentionally rejected**: Rebuilding the full website now. That would expand scope before the homepage direction is proven. +- **Release truth**: Current public website direction, not platform runtime truth or future product data truth. + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility for old public template pages, old TenantAtlas/TenantPilot public copy, deleted demo routes, and template homepage content is out of scope unless explicitly required by a later spec. Public pages reachable from the updated header/footer may receive narrow brand/SEO copy cleanup so the website does not expose a mixed Tenantial/TenantAtlas public brand. + +Internal package names, workspace scripts, and platform runtime contracts must not be renamed by this feature. In particular, the internal workspace package name `@tenantatlas/website` remains unchanged. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Browser. +- **Validation lane(s)**: browser. +- **Why this classification and these lanes are sufficient**: The feature changes a public static website route, visual hierarchy, responsive behavior, copy, and SEO metadata. Browser smoke checks and screenshot review are the narrowest honest proof. +- **New or expanded test families**: Homepage website smoke coverage only. +- **Fixture / helper cost impact**: None. No database, workspace, membership, provider, session, seed, or platform setup should be required. +- **Heavy-family visibility / justification**: Browser coverage is intentional and scoped to the homepage shell, visual behavior, and template-residue checks. +- **Special surface test profile**: N/A. +- **Standard-native relief or required special coverage**: Homepage smoke checks plus desktop/mobile visual review. +- **Reviewer handoff**: Reviewers must confirm that browser coverage remains website-scoped, no hidden platform setup is introduced, and the proof checks visible content, metadata cleanup, and mobile overflow. +- **Budget / baseline / trend impact**: None expected beyond a small homepage smoke suite. +- **Escalation needed**: document-in-feature. +- **Active feature PR close-out entry**: Smoke Coverage. +- **Planned validation commands**: repository website build command; website browser smoke test command; `git diff --check`; desktop and mobile browser review when screenshots are intentionally captured. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Understand Tenantial Immediately (Priority: P1) + +A first-time visitor opens the homepage and understands that Tenantial is an evidence-first governance platform for Microsoft tenants, not a generic SaaS template. + +**Why this priority**: This is the minimum viable homepage outcome. If the first viewport does not establish the brand, category, and core promise, the rebuild fails. + +**Independent Test**: Can be tested by opening `/` and verifying that the first viewport shows the Tenantial brand, the evidence-first governance headline, Microsoft tenant context, and primary/secondary CTAs. + +**Acceptance Scenarios**: + +1. **Given** a first-time visitor, **When** they open `/`, **Then** they see Tenantial as the public brand and the headline "Evidence-first governance for Microsoft tenants." +2. **Given** a visitor scanning the hero, **When** they read the supporting copy, **Then** they can identify backup, restore, drift detection, snapshot-backed audit context, evidence, and structured reviews as the product focus. +3. **Given** a visitor looking for the next step, **When** they inspect the hero actions, **Then** "Book a demo" is the primary CTA and "Explore the platform" is secondary. + +--- + +### User Story 2 - Evaluate Trust Without False Proof (Priority: P2) + +An IT, MSP, security, or compliance stakeholder reviews the homepage and sees credible trust positioning without fake customer logos, unsupported certifications, or exaggerated availability/security claims. + +**Why this priority**: Tenantial operates in a governance and evidence domain; credibility depends on restrained claims and clear proof boundaries. + +**Independent Test**: Can be tested by reviewing the trust bar, feature pillars, static preview, and footer for allowed claims and absence of unverified social proof. + +**Acceptance Scenarios**: + +1. **Given** no verified customer logos are available, **When** the trust bar renders, **Then** it uses neutral credibility statements rather than named real customer logos. +2. **Given** no verified security certifications are provided, **When** the homepage renders, **Then** it does not claim SOC 2, ISO certification, uptime guarantees, or "trusted by" proof. +3. **Given** a stakeholder reviewing capabilities, **When** they reach the feature pillars, **Then** Backup, Restore, Drift Detection, Evidence, Audit Trail, and Governance Reviews are all present with concise descriptions. + +--- + +### User Story 3 - Inspect A Product-Near Preview (Priority: P3) + +A visitor sees a product-near dashboard preview that communicates posture, findings, drift, evidence, backup status, reviews, and recent activity while clearly remaining static marketing content. + +**Why this priority**: The mockup direction depends on a strong preview surface, but false live-data implications would undermine trust. + +**Independent Test**: Can be tested by checking that the dashboard preview is visible, readable, responsive, and framed as static demo content. + +**Acceptance Scenarios**: + +1. **Given** the homepage has loaded, **When** a visitor views the preview, **Then** they see static demo values for overall posture, findings, drift detected, evidence items, and backup status. +2. **Given** the preview contains status information, **When** colors are removed or unavailable, **Then** labels or text still communicate the status meaning. +3. **Given** a visitor is on mobile, **When** they reach the preview, **Then** the page remains usable without body-level horizontal overflow. + +--- + +### User Story 4 - Verify Public Launch Readiness (Priority: P4) + +A website owner reviews the homepage before launch and can confirm that old template residue, wrong brand names, broken CTAs, and unsupported promises are absent. + +**Why this priority**: The rebuild should establish a clean baseline before later public pages reuse the direction. + +**Independent Test**: Can be tested through homepage smoke checks, metadata review, link review, and desktop/mobile visual review. + +**Acceptance Scenarios**: + +1. **Given** the homepage is ready for review, **When** the visible copy and metadata are checked, **Then** AstroDeck/template positioning and old public brand names are absent. +2. **Given** a reviewer inspects navigation, **When** they follow homepage CTAs and nav links, **Then** links are intentional and do not silently point to dead template routes. +3. **Given** a reviewer checks desktop and mobile, **When** the page is visually reviewed, **Then** text does not clip, CTAs remain visible, and the header adapts to the viewport. + +### Edge Cases + +- If dedicated destination pages do not yet exist, homepage links must still be intentional and must not point to stale template routes. +- If no real logo asset is available, the homepage may use a simple Tenantial mark that does not imply a third-party endorsement. +- If no real customer proof is available, the trust bar must use neutral product-focus statements instead of fake logos or named companies. +- If a visitor uses a narrow mobile viewport, the hero, CTA text, header, and dashboard preview must not create body-level horizontal overflow. +- If motion reduction is preferred by the visitor, decorative movement must not be required to understand the page. +- If status colors are not distinguishable, status labels and text must still communicate meaning. +- If platform authentication is not implemented or no sign-in destination is known, homepage sign-in treatment must not imply a completed login flow. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature introduces no Microsoft Graph calls, no write/change behavior, no queued or scheduled work, no OperationRun, no AuditLog changes, no tenant isolation changes, and no platform runtime behavior. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces no persisted product truth, no domain abstraction, no enum/status/reason family, and no cross-domain UI framework. The only new source of truth is the public homepage brand/content direction, bounded to the website. + +**Constitution alignment (XCUT-001):** This feature does not touch shared operator interaction families. Public website navigation and marketing preview elements must remain website-local. + +**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** This feature does not change admin detail/status surfaces, audience modes, support/raw evidence disclosure, or operator next-action surfaces. + +**Constitution alignment (PROV-001):** This feature does not change provider/platform seams. Microsoft tenant language is marketing positioning only and must not create runtime provider coupling. + +**Constitution alignment (TEST-GOV-001):** Browser coverage is intentional, homepage-scoped, and must not introduce database, workspace, provider, membership, session, or platform defaults. + +**Constitution alignment (OPS-UX / OPS-UX-START-001):** N/A - no OperationRun behavior is created, queued, completed, deduplicated, resumed, blocked, or linked. + +**Constitution alignment (RBAC-UX):** N/A - no authorization plane, capability, membership, global search, mutation, or destructive action behavior changes. + +**Constitution alignment (BADGE-001):** N/A - no admin status badge semantics change. Public marketing status labels in the static preview must be descriptive and not become a shared status taxonomy. + +**Constitution alignment (UI-FIL-001):** N/A - no Filament, Blade admin, Livewire, Resource, RelationManager, or Page changes. + +**Constitution alignment (UI-NAMING-001 / DECIDE-001 / UI surface rules):** N/A for operator-facing naming and action-surface contracts. Public homepage copy must still use calm, precise, user-facing language and avoid implementation-first labels. + +### Functional Requirements + +- **FR-001**: `/` MUST render a Tenantial-specific public homepage. +- **FR-002**: The homepage MUST present Tenantial as an evidence-first governance platform for Microsoft tenants. +- **FR-003**: The homepage MUST use a premium dark enterprise visual direction with high-contrast text and restrained accent colors. +- **FR-004**: The homepage MUST show the headline "Evidence-first governance for Microsoft tenants." +- **FR-005**: The homepage MUST include supporting copy that communicates backup, restore, drift detection, snapshot-backed audit context, verifiable evidence, and structured governance reviews. +- **FR-006**: "Book a demo" MUST be visible as the primary CTA. +- **FR-007**: "Explore the platform" MUST be visible as the secondary CTA. +- **FR-008**: CTA and navigation destinations MUST be intentional and MUST NOT silently point to dead template routes. +- **FR-009**: If no implemented sign-in destination is known, "Sign in" MUST NOT be presented as a prominent functional promise. +- **FR-010**: The desktop header MUST include Tenantial branding and navigation labels for Platform, Solutions, Resources, Pricing, Company, Sign in, and Book a demo. +- **FR-011**: The homepage MUST include a static product-near dashboard preview. +- **FR-012**: The dashboard preview MUST be clearly static marketing/demo content and MUST NOT depend on backend, API, authentication, tenant, or platform data. +- **FR-013**: The dashboard preview MUST show static demo values for overall posture, findings, drift detected, evidence items, and backup status. +- **FR-014**: The dashboard preview MUST include supporting areas for recent findings, drift timeline, backups and restores, reviews, and evidence spotlight. +- **FR-015**: The homepage MUST include a trust bar using neutral credibility statements such as Microsoft tenant focus, evidence-oriented workflows, audit review workflow design, and operator-led governance. +- **FR-016**: The trust bar MUST NOT use real customer logos, certification claims, uptime claims, "trusted by" statements, or security/compliance promises unless independently verified. +- **FR-017**: The feature pillars MUST include Backup, Restore, Drift Detection, Evidence, Audit Trail, and Governance Reviews. +- **FR-018**: Feature pillar copy MUST remain concise, product-specific, and free of unsupported compliance or security promises. +- **FR-019**: The homepage MUST include a final CTA section with "Build tenant governance on evidence, not assumptions." or equivalent wording with the same meaning. +- **FR-020**: The homepage footer MUST include Tenantial branding and footer link groups for Platform, Solutions, Resources, Pricing, Company, Contact, Legal, Privacy, and Security. +- **FR-021**: Visible homepage copy MUST NOT contain AstroDeck, template demo positioning, Open Source, MIT Licensed, TenantCTRL, TenantPilot, or TenantAtlas as public brand names. +- **FR-022**: Homepage SEO metadata MUST be Tenantial-specific and MUST describe evidence-first governance for Microsoft tenants. +- **FR-023**: Homepage metadata MUST NOT contain AstroDeck/template titles, old public brand names, fake customer proof, or unverified certification claims. +- **FR-024**: The homepage MUST have exactly one primary page heading. +- **FR-025**: Keyboard users MUST be able to reach and identify interactive homepage controls, including navigation and CTAs. +- **FR-026**: Status information in the dashboard preview MUST NOT rely on color alone. +- **FR-027**: The homepage MUST provide sufficient contrast on the dark background for body copy, labels, links, and CTAs. +- **FR-028**: The homepage MUST remain usable on mobile, tablet, desktop, and wide desktop viewports. +- **FR-029**: The mobile homepage MUST NOT create body-level horizontal overflow. +- **FR-030**: The header MUST collapse or simplify on mobile without hiding essential navigation and CTA access from keyboard users. +- **FR-031**: Decorative icons or visual flourishes MUST NOT be required to understand the page content. +- **FR-032**: Decorative motion, if present, MUST be subtle and respect reduced-motion preferences. +- **FR-033**: The homepage MUST NOT introduce platform, auth, database, Microsoft Graph, Filament, Livewire, or runtime API coupling. +- **FR-034**: Existing root workspace script names and website port conventions MUST remain unchanged. + +### Key Entities + +- **Homepage Message**: The public brand, category, headline, supporting promise, and CTA hierarchy for the `/` route. +- **Static Dashboard Preview**: A product-near visual using fixed demo values to communicate governance posture, findings, drift, evidence, backup status, reviews, and recent activity. +- **Trust Statement**: A neutral credibility statement that supports confidence without implying verified customer proof, certifications, or availability guarantees. +- **Feature Pillar**: A concise capability card describing one of Tenantial's required homepage capabilities: Backup, Restore, Drift Detection, Evidence, Audit Trail, or Governance Reviews. +- **Footer Link Group**: A public website navigation group that points visitors toward product, company, contact, legal, privacy, and security information without template residue. + +### Assumptions + +- The public brand for this homepage is Tenantial. +- No verified customer logos, security certifications, or uptime claims are available for this slice. +- The "Book a demo" destination defaults to `/contact` unless a valid existing destination is confirmed before implementation. +- The "Explore the platform" destination defaults to `/product` until a dedicated platform route exists. +- Header route defaults: Platform -> `/product`, Solutions -> `/solutions`, Book a demo -> `/contact`, and Explore the platform -> `/product`. Resources, Pricing, Company, and Sign in must not link to missing same-name routes; they may be de-emphasized, mapped to an existing intentional route, or rendered non-prominently until real routes exist. +- No secondary public pages are built by this spec. +- The dashboard preview uses static demo content, not screenshots, live product data, or platform API data. +- Existing website font choices may be reused if they support the premium enterprise direction. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In a stakeholder review, at least 8 of 10 reviewers can identify Tenantial as a Microsoft tenant governance platform within 5 seconds of viewing the first homepage viewport. +- **SC-002**: 100% of required homepage sections render on desktop and mobile review: header, hero, static dashboard preview, trust bar, feature pillars, CTA section, and footer. +- **SC-003**: Review of visible copy and homepage metadata finds 0 occurrences of AstroDeck, Open Source, MIT Licensed, TenantCTRL, TenantPilot, TenantAtlas, or other old/template public brand positioning. +- **SC-004**: Desktop and mobile review confirms 0 body-level horizontal overflow issues at narrow mobile, tablet, desktop, and wide desktop widths. +- **SC-005**: At least 90% of reviewers can identify Backup, Restore, Drift Detection, Evidence, Audit Trail, and Governance Reviews from the homepage without needing external documentation. +- **SC-006**: Trust review finds 0 unverified customer, certification, uptime, or security/compliance claims. +- **SC-007**: Accessibility review confirms the page has one primary heading, keyboard-visible controls, readable contrast on dark backgrounds, and non-color-only status meaning. +- **SC-008**: Homepage smoke validation confirms the route renders, required brand/capability copy is visible, required CTAs are visible, static preview content is present, and mobile overflow is absent. diff --git a/specs/400-tenantial-homepage-visual-rebuild/tasks.md b/specs/400-tenantial-homepage-visual-rebuild/tasks.md new file mode 100644 index 00000000..12228451 --- /dev/null +++ b/specs/400-tenantial-homepage-visual-rebuild/tasks.md @@ -0,0 +1,257 @@ +# Tasks: Tenantial Homepage Visual Rebuild + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/` +**Prerequisites**: [plan.md](/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/plan.md), [spec.md](/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/spec.md), [research.md](/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/research.md), [data-model.md](/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/data-model.md), [contracts/public-homepage.yaml](/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/contracts/public-homepage.yaml), [quickstart.md](/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/quickstart.md) + +**Tests**: Required. Spec 400 changes public website runtime behavior and requires browser smoke coverage. Test tasks use Playwright only; no Pest/Laravel/Filament tests are needed because no platform behavior changes. + +**Organization**: Tasks are grouped by user story so each story can be implemented and verified as an independent increment after shared setup/foundation. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Confirm the existing website structure, route inventory, test files, and styling baseline before editing. + +- [X] T001 Review Spec 400 planning inputs in `/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/data-model.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/contracts/public-homepage.yaml` +- [X] T002 Verify current homepage assembly and content sources in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/pages/index.astro`, `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/content/pages/home.ts`, and `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/lib/site.ts` +- [X] T003 [P] Verify current homepage smoke and IA assertions in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke/home-product.spec.ts`, `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke/smoke-helpers.ts`, `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke/visual-foundation-guardrails.spec.ts`, and `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke/changelog-core-ia.spec.ts` +- [X] T004 [P] Verify Tailwind v4 CSS-first styling constraints in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/styles/tokens.css`, `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/styles/global.css`, and `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/package.json` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish shared Tenantial brand, dark shell, route intent, and smoke-test helpers used by all user stories. + +**Critical**: No user story implementation should begin until this phase is complete. + +- [X] T005 Update public site metadata, primary navigation seeds, footer group seeds, and route intent for Tenantial in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/lib/site.ts` +- [X] T006 Update homepage smoke helper constants and shared assertions for Tenantial labels, footer labels, forbidden old brands, metadata checks, and route targets in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke/smoke-helpers.ts` +- [X] T007 Update global Tenantial homepage SEO defaults and old-brand cleanup assumptions in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/content/pages/home.ts` +- [X] T008 [P] Replace the light TenantAtlas color foundation with website-local dark Tenantial design tokens using Tailwind v4 CSS-first `@theme` in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/styles/tokens.css` +- [X] T009 [P] Update body background, shell surfaces, focus-visible styling, reduced-motion behavior, and horizontal-overflow safeguards in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/styles/global.css` +- [X] T010 Update shared public shell styling and Tenantial brand presentation in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/components/layout/PageShell.astro`, `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/components/layout/Navbar.astro`, and `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/components/layout/Footer.astro` + +**Checkpoint**: Shared Tenantial brand shell, route intent, dark tokens, and test helpers are ready. + +--- + +## Phase 3: User Story 1 - Understand Tenantial Immediately (Priority: P1) - MVP + +**Goal**: A first-time visitor opens `/` and immediately understands Tenantial as an evidence-first governance platform for Microsoft tenants with clear primary and secondary CTAs. + +**Independent Test**: Open `/` and verify Tenantial branding, the headline "Evidence-first governance for Microsoft tenants.", Microsoft tenant context, `Book a demo`, `Explore the platform`, and exactly one primary page heading. + +### Tests for User Story 1 + +- [X] T011 [US1] Update homepage hero smoke assertions for Tenantial brand, headline, supporting copy, CTA hierarchy, one H1, and `/contact` plus `/product` route targets in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke/home-product.spec.ts` + +### Implementation for User Story 1 + +- [X] T012 [US1] Replace homepage hero copy, trust bullets, CTA labels, CTA destinations, and SEO metadata with Tenantial first-read content in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/content/pages/home.ts` +- [X] T013 [US1] Adapt the homepage hero layout, heading scale, eyebrow treatment, CTA pair, and first-viewport composition in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/components/sections/PageHero.astro` +- [X] T014 [US1] Update homepage section ordering so the hero and first-read CTA story appear before supporting sections in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/pages/index.astro` +- [X] T015 [US1] Implement desktop and mobile header behavior for Platform -> `/product`, Solutions -> `/solutions`, Book a demo -> `/contact`, and explicit non-dead handling for Resources, Pricing, Company, and Sign in in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/components/layout/Navbar.astro` +- [X] T016 [US1] Run the User Story 1 smoke subset from `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke/home-product.spec.ts` and confirm it fails before implementation and passes after implementation + +**Checkpoint**: User Story 1 is independently testable as the homepage MVP. + +--- + +## Phase 4: User Story 2 - Evaluate Trust Without False Proof (Priority: P2) + +**Goal**: An IT, MSP, security, or compliance stakeholder sees credible trust positioning, neutral claims, and the required capability pillars without fake proof. + +**Independent Test**: Review `/` and confirm neutral trust statements, no unverified customer/certification/uptime claims, and visible Backup, Restore, Drift Detection, Evidence, Audit Trail, and Governance Reviews pillars. + +### Tests for User Story 2 + +- [X] T017 [US2] Add homepage smoke assertions for TrustBar statements, required FeaturePillars, and absence of SOC 2, ISO, uptime, trusted-by, and real-customer-logo claims in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke/home-product.spec.ts` + +### Implementation for User Story 2 + +- [X] T018 [P] [US2] Create the static trust bar section with neutral credibility statements in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/components/sections/TrustBar.astro` +- [X] T019 [P] [US2] Create the six required feature pillar cards with accessible icon treatment in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/components/sections/FeaturePillars.astro` +- [X] T020 [US2] Add TrustBar and FeaturePillars content data for Backup, Restore, Drift Detection, Evidence, Audit Trail, and Governance Reviews in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/content/pages/home.ts` +- [X] T021 [US2] Wire TrustBar and FeaturePillars into the homepage after the hero section using a stable placeholder position that does not depend on the final DashboardPreview implementation in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/pages/index.astro` +- [X] T022 [US2] Remove or replace old trust, logo-strip, and capability copy that conflicts with Tenantial's no-fake-proof boundary in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/pages/index.astro` +- [X] T023 [US2] Run the User Story 2 smoke subset from `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke/home-product.spec.ts` and confirm trust/capability assertions pass + +**Checkpoint**: User Story 2 is independently testable after shared shell and homepage assembly are available. + +--- + +## Phase 5: User Story 3 - Inspect A Product-Near Preview (Priority: P3) + +**Goal**: A visitor sees a static product-near dashboard preview with posture, findings, drift, evidence, backup status, reviews, and recent activity that remains responsive and clearly non-live. + +**Independent Test**: Open `/`, inspect the preview, and verify static demo values `92%`, `14`, `7`, `1,248`, `98%`, supporting panels, text-based status meaning, no live/realtime claim, and no body-level horizontal overflow on mobile. + +### Tests for User Story 3 + +- [X] T024 [US3] Add homepage smoke assertions for static dashboard metrics, supporting panels, non-live wording, and status text labels in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke/home-product.spec.ts` +- [X] T025 [P] [US3] Add a reusable mobile body-overflow assertion for homepage smoke tests in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke/smoke-helpers.ts` + +### Implementation for User Story 3 + +- [X] T026 [US3] Create the static HTML/CSS dashboard preview with metrics, recent findings, drift timeline, backups and restores, reviews, and evidence spotlight in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/components/content/DashboardPreview.astro` +- [X] T027 [US3] Replace the old hero visual usage with DashboardPreview in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/components/sections/PageHero.astro` +- [X] T028 [US3] Add dashboard-specific responsive layout, status label, and overflow containment styling in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/styles/global.css` +- [X] T029 [US3] Remove stale hero screenshot references from homepage content so the static dashboard preview is the only homepage product visual in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/content/pages/home.ts` +- [X] T030 [US3] Run the User Story 3 smoke subset from `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke/home-product.spec.ts` with desktop and 390px mobile viewport coverage + +**Checkpoint**: User Story 3 is independently testable as a static, responsive, non-live product preview. + +--- + +## Phase 6: User Story 4 - Verify Public Launch Readiness (Priority: P4) + +**Goal**: A website owner can verify that old template residue, wrong brand names, unsupported promises, broken CTAs, and mobile layout issues are absent before launch. + +**Independent Test**: Run the homepage smoke suite and manually review desktop/mobile `/` for forbidden copy, metadata cleanup, intentional links, footer completeness, no clipping, and no body overflow. + +### Tests for User Story 4 + +- [X] T031 [US4] Add forbidden visible-copy and metadata assertions for AstroDeck, Open Source, MIT Licensed, TenantCTRL, TenantPilot, and TenantAtlas in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke/home-product.spec.ts` +- [X] T032 [P] [US4] Update core IA route and hidden-label assertions for Tenantial header/footer expectations in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke/changelog-core-ia.spec.ts` +- [X] T033 [P] [US4] Update visual foundation guardrail smoke expectations for Tenantial brand, CTA text, and dark surface semantics in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke/visual-foundation-guardrails.spec.ts` + +### Implementation for User Story 4 + +- [X] T034 [US4] Update final CTA copy and homepage CTA section wiring to "Build tenant governance on evidence, not assumptions." with Book a demo and Explore the platform in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/components/sections/CTASection.astro` and `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/content/pages/home.ts` +- [X] T035 [US4] Update footer copy, footer group labels, copyright text, and old-template cleanup in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/components/layout/Footer.astro` +- [X] T036 [P] [US4] Update the public Tenantial favicon or simple local mark in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/public/favicon.svg` +- [X] T037 [US4] Remove the unused old product-visual asset in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/public/images/hero-product-visual.svg` and replace old visual references in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/content/pages/home.ts` +- [X] T038 [US4] Run a forbidden-copy scrub across `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src` and update homepage-visible and globally reachable public brand residue found in the affected source files + +**Checkpoint**: User Story 4 is independently testable as launch-readiness cleanup and smoke validation. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Validate the complete feature, record test governance, and ensure no scope drift into platform/admin behavior. + +- [X] T039 Run `corepack pnpm build:website` from `/Users/ahmeddarrazi/Documents/projects/wt-website/package.json` and fix any build failures in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website` +- [X] T040 Run `WEBSITE_PORT=4321 corepack pnpm --filter @tenantatlas/website test:smoke` from `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/package.json` and fix any smoke failures in `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/tests/smoke` +- [X] T041 Use browser review for mobile, tablet, desktop, and wide desktop `/` viewports, including keyboard focus order for header navigation, mobile menu, and CTAs, against `/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/quickstart.md` +- [X] T042 [P] Verify no Laravel, Filament, Livewire, Microsoft Graph, database, auth, OperationRun, or platform coupling was introduced under `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website` +- [X] T043 [P] Verify Tailwind v4 compliance by checking no deprecated v3 utilities or `tailwind.config.js` assumptions were introduced under `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/styles` and `/Users/ahmeddarrazi/Documents/projects/wt-website/apps/website/src/components` +- [X] T044 Record final Smoke Coverage close-out notes and any residual browser-review caveats in `/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/tasks.md` + +## Smoke Coverage Close-Out + +- Build: `corepack pnpm build:website` passed on 2026-05-18. Astro emitted non-failing content-loader warnings for missing `src/content/articles/` and `src/content/resources/` directories. +- Browser smoke: `WEBSITE_PORT=4321 corepack pnpm --filter @tenantatlas/website test:smoke` passed with 19/19 tests on 2026-05-18. +- Diff hygiene: `git diff --check` passed on 2026-05-18. +- Responsive/browser coverage: homepage smoke includes 390px mobile overflow checks, CTA hierarchy, metadata residue checks, static dashboard preview checks, header/mobile navigation, TrustBar, FeaturePillars, and footer reachability. +- Screenshots: no screenshot artifacts are committed for this branch. Earlier local-only screenshot paths were removed from this close-out so review remains repo-reproducible; use the quickstart manual review steps when fresh screenshots are needed. +- Scope/coupling: no Laravel, Filament, Livewire, database, auth, OperationRun, queue, or platform runtime behavior was introduced. Remaining `Microsoft Graph` and `database` search hits are pre-existing supporting-page/integration content, not new homepage coupling. +- Tailwind: no deprecated v3 utility patterns or `tailwind.config.js` assumptions were introduced in the checked website styles/components. +- Brand cleanup: existing public pages reachable from header/footer use Tenantial public brand copy while internal workspace names such as `@tenantatlas/website` remain unchanged. +- Spec 227: no `specs/227-*` artifact exists in the current checkout; no phantom spec was created. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 Setup**: No dependencies. +- **Phase 2 Foundational**: Depends on Phase 1 and blocks all user stories. +- **Phase 3 US1**: Depends on Phase 2 and is the MVP. +- **Phase 4 US2**: Depends on Phase 2; component creation and homepage wiring can proceed independently of US3 by using a stable post-hero section position. +- **Phase 5 US3**: Depends on Phase 2; dashboard component creation can begin after foundation, but final hero integration should account for US1 edits to `PageHero.astro`. +- **Phase 6 US4**: Depends on US1, US2, and US3 because it verifies final launch readiness across copy, metadata, navigation, footer, and visual behavior. +- **Phase 7 Polish**: Depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: Independent MVP after foundation. +- **US2 (P2)**: Independently testable after foundation; integrates with homepage assembly. +- **US3 (P3)**: Independently testable after foundation; integrates with hero/dashboard visual. +- **US4 (P4)**: Final readiness story; depends on the implemented homepage surface from US1-US3. + +### Within Each User Story + +- Write or update Playwright assertions first and confirm they fail before implementing the story. +- Update content/data before page wiring when both are needed. +- Create new components before importing them into `index.astro` or `PageHero.astro`. +- Run the story-specific smoke subset before proceeding to the next story. + +--- + +## Parallel Execution Examples + +### User Story 1 + +```text +Sequential because US1 edits shared homepage and hero files: +T011 -> T012 -> T013 -> T014 -> T015 -> T016 +``` + +### User Story 2 + +```text +Parallel component work after T017: +Task T018: Create TrustBar.astro +Task T019: Create FeaturePillars.astro + +Then: +T020 -> T021 -> T022 -> T023 +``` + +### User Story 3 + +```text +Parallel test helper and component work: +Task T025: Add mobile overflow helper +Task T026: Create DashboardPreview.astro + +Then: +T027 -> T028 -> T029 -> T030 +``` + +### User Story 4 + +```text +Parallel readiness assertions after US1-US3: +Task T032: Update changelog-core-ia.spec.ts +Task T033: Update visual-foundation-guardrails.spec.ts +Task T036: Update favicon.svg + +Then: +T031 -> T034 -> T035 -> T037 -> T038 +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1 Setup. +2. Complete Phase 2 Foundational tasks. +3. Complete Phase 3 User Story 1. +4. Stop and validate `/` as a Tenantial first-read homepage MVP. +5. Continue only after the hero, brand, headline, CTA hierarchy, and basic navigation behavior pass. + +### Incremental Delivery + +1. **US1**: Tenantial first-read homepage and CTA hierarchy. +2. **US2**: Trust bar and feature pillars without false proof. +3. **US3**: Static product-near dashboard preview. +4. **US4**: Launch-readiness cleanup, metadata, footer, forbidden-copy checks, and mobile review. + +### Parallel Team Strategy + +1. Complete Setup and Foundation together. +2. Split US2 TrustBar and FeaturePillars work while US3 DashboardPreview work proceeds in a separate file. +3. Serialize edits to shared files: `index.astro`, `home.ts`, `PageHero.astro`, `global.css`, and smoke specs. +4. Reserve US4 for final cleanup after all homepage sections exist. + +## Notes + +- `[P]` tasks are safe to start in parallel because they touch different files or do not depend on incomplete same-file edits. +- Story labels map to the four user stories in [spec.md](/Users/ahmeddarrazi/Documents/projects/wt-website/specs/400-tenantial-homepage-visual-rebuild/spec.md). +- Do not introduce platform, auth, database, Microsoft Graph, Laravel, Filament, Livewire, queue, or OperationRun behavior. +- Do not rename root packages or existing workspace scripts from `@tenantatlas/website` or the current pnpm script names. -- 2.45.2